diff --git a/apps/mobile/app/(app)/[workspace]/new-issue.tsx b/apps/mobile/app/(app)/[workspace]/new-issue.tsx index e3366fefcc..504790c12d 100644 --- a/apps/mobile/app/(app)/[workspace]/new-issue.tsx +++ b/apps/mobile/app/(app)/[workspace]/new-issue.tsx @@ -1,19 +1,370 @@ -import { View } from "react-native"; -import { Text } from "@/components/ui/text"; - /** - * Quick-create issue modal (placeholder). + * New issue creation modal. * - * Wired in via Stack.Screen presentation="modal" in [workspace]/_layout.tsx. - * Opens from the global header `+` button (HeaderActions). Form + submit - * land in a later phase. + * Modes (mirrors web's `useCreateModeStore`): + * - Manual: title + description + status/priority/assignee chips. + * Fully wired to `apiClient.issues.create` via useCreateIssue(). + * Description supports inline `@mention` (members + agents). + * - Agent: natural-language prompt + agent picker (placeholder; Phase 3 + * wires the real picker + apiClient.quickCreateIssue). + * + * Manual-mode state lives at the top level (title / description / status / + * priority / assignee / mention markers / selection / mentioning). The + * `ManualPanel` is a controlled child — submit button lives in the Stack + * header so it needs to read `canSubmit` and call `onSubmit` from this + * scope. + * + * Mention pattern mirrors `comment-composer.tsx` exactly: + * 1. `onChangeText` + `onSelectionChange` recompute `tokenAtCursor` to + * detect an active `@query` at the caret. + * 2. `MentionSuggestionBar` renders above the chip row when there's an + * active mention; picking calls `insertMention` and pushes to markers. + * 3. On submit, `serializeMentions(description, markers)` produces the + * canonical `[@name](mention://type/id)` markdown — same wire format + * as web's Tiptap mention extension. Server's util.ParseMentions and + * mobile's mention-chip both parse it back. + * + * Defaults (status="todo", priority="none") mirror web's ManualCreatePanel — + * "Behavioral parity" rule (apps/mobile/CLAUDE.md). */ +import { useCallback, useMemo, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + TextInput, + View, + type NativeSyntheticEvent, + type TextInputSelectionChangeEventData, +} from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { Stack, router } from "expo-router"; +import type { IssuePriority, IssueStatus } from "@multica/core/types"; +import { SubmitIssueButton } from "@/components/issue/submit-issue-button"; +import { CreateFormAttributeRow } from "@/components/issue/create-form-attribute-row"; +import { + CreateModeToggle, + type CreateMode, +} from "@/components/issue/create-mode-toggle"; +import type { AssigneeValue } from "@/components/issue/pickers/assignee-picker-sheet"; +import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar"; +import { Text } from "@/components/ui/text"; +import { useCreateIssue } from "@/data/mutations/issues"; +import { + insertMention, + serializeMentions, + tokenAtCursor, + type MentionMarker, +} from "@/lib/mention-serialize"; + +interface MentioningState { + start: number; + query: string; +} + export default function NewIssueModal() { + const [mode, setMode] = useState("manual"); + + // Manual mode fields + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState("todo"); + const [priority, setPriority] = useState("none"); + const [assignee, setAssignee] = useState(null); + + // Mention state (description only — web doesn't allow mentions in title) + const [markers, setMarkers] = useState([]); + const [selection, setSelection] = useState<{ start: number; end: number }>({ + start: 0, + end: 0, + }); + const [mentioning, setMentioning] = useState(null); + + // Agent mode fields (Phase 3 wires the picker) + const [prompt, setPrompt] = useState(""); + const [agentId] = useState(null); + + const createIssue = useCreateIssue(); + const isSubmitting = createIssue.isPending; + + const canSubmit = + !isSubmitting && + (mode === "manual" + ? title.trim().length > 0 + : prompt.trim().length > 0); + + const recomputeMentioning = useCallback( + (text: string, cursor: number) => { + const token = tokenAtCursor(text, cursor); + if (token) { + setMentioning({ start: token.start, query: token.query }); + } else if (mentioning) { + setMentioning(null); + } + }, + [mentioning], + ); + + const onDescriptionChange = useCallback( + (next: string) => { + setDescription(next); + recomputeMentioning(next, selection.end); + }, + [recomputeMentioning, selection.end], + ); + + const onDescriptionSelectionChange = useCallback( + (e: NativeSyntheticEvent) => { + const sel = e.nativeEvent.selection; + setSelection(sel); + recomputeMentioning(description, sel.end); + }, + [recomputeMentioning, description], + ); + + const onSelectMention = useCallback( + (mention: MentionMarker) => { + if (!mentioning) return; + const { newText, newSelection, marker } = insertMention( + description, + { start: mentioning.start, queryLength: mentioning.query.length }, + mention, + ); + setDescription(newText); + setSelection(newSelection); + setMarkers((prev) => [...prev, marker]); + setMentioning(null); + }, + [mentioning, description], + ); + + const onSubmit = useCallback(async () => { + if (mode === "manual") { + const trimmedTitle = title.trim(); + if (trimmedTitle.length === 0) return; + const finalDescription = serializeMentions(description, markers).trim(); + try { + await createIssue.mutateAsync({ + title: trimmedTitle, + description: finalDescription || undefined, + status, + priority, + ...(assignee + ? { assignee_type: assignee.type, assignee_id: assignee.id } + : {}), + }); + router.back(); + } catch (err) { + Alert.alert( + "Failed to create issue", + err instanceof Error ? err.message : "Unknown error", + ); + } + } else { + // Agent mode — Phase 3 swaps this for apiClient.quickCreateIssue. + if (prompt.trim().length === 0) return; + console.log("[new-issue] submit (agent)", { + prompt: prompt.trim(), + agent_id: agentId, + }); + router.back(); + } + }, [ + mode, + title, + description, + markers, + status, + priority, + assignee, + prompt, + agentId, + createIssue, + ]); + + const headerRight = useMemo(() => { + function HeaderRight() { + return ( + + ); + } + return HeaderRight; + }, [canSubmit, isSubmitting, onSubmit]); + + const headerTitle = useMemo(() => { + function HeaderTitle() { + return ; + } + return HeaderTitle; + }, [mode]); + return ( - - - New issue form coming soon. - - + <> + + + {mode === "manual" ? ( + + ) : ( + + )} + + ); } + +function ManualPanel({ + title, + onTitleChange, + description, + onDescriptionChange, + descriptionSelection, + onDescriptionSelectionChange, + status, + onStatusChange, + priority, + onPriorityChange, + assignee, + onAssigneeChange, + mentioning, + onSelectMention, + submitting, +}: { + title: string; + onTitleChange: (next: string) => void; + description: string; + onDescriptionChange: (next: string) => void; + descriptionSelection: { start: number; end: number }; + onDescriptionSelectionChange: ( + e: NativeSyntheticEvent, + ) => void; + status: IssueStatus; + onStatusChange: (next: IssueStatus) => void; + priority: IssuePriority; + onPriorityChange: (next: IssuePriority) => void; + assignee: AssigneeValue; + onAssigneeChange: (next: AssigneeValue) => void; + mentioning: MentioningState | null; + onSelectMention: (mention: MentionMarker) => void; + submitting: boolean; +}) { + return ( + <> + + + + + + + + + + + ); +} + +function AgentPanel({ + prompt, + onPromptChange, +}: { + prompt: string; + onPromptChange: (next: string) => void; +}) { + return ( + <> + + + + + + {/* Phase 3 will replace this with a real agent picker sheet. */} + { + console.log("[new-issue] agent picker — Phase 3"); + }} + className="flex-row items-center gap-2 px-3 py-2 rounded-full border border-dashed border-muted-foreground/30 self-start active:bg-secondary" + hitSlop={4} + > + + + Agent: Select + + + + + + ); +} + diff --git a/apps/mobile/components/issue/create-form-attribute-row.tsx b/apps/mobile/components/issue/create-form-attribute-row.tsx new file mode 100644 index 0000000000..cf10f9ce14 --- /dev/null +++ b/apps/mobile/components/issue/create-form-attribute-row.tsx @@ -0,0 +1,125 @@ +/** + * Bottom chip row + picker sheets for the new-issue form. Mirrors + * `attribute-row.tsx`'s visual pattern but operates on form state + * (controlled props + setters) instead of an `issue` object + mutation. + * + * Phase 1: status / priority / assignee. Phase 2 adds label, project, + * due_date, parent as additional chips appended to the same row — the + * horizontal ScrollView already handles overflow. + * + * Reuses (zero-modification): + * - StatusPickerSheet / PriorityPickerSheet / AssigneePickerSheet + * - AttributeChip + * - StatusIcon / PriorityIcon / ActorAvatar + * + * Chip "value present" rule: a chip is `filled` when the form value + * differs from the default (todo / none / null). When at default it + * renders `dimmed` with a placeholder label ("Priority", "Assignee"). + */ +import { useState } from "react"; +import { ScrollView, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import type { IssuePriority, IssueStatus } from "@multica/core/types"; +import { AttributeChip } from "@/components/issue/attribute-chip"; +import { + AssigneePickerSheet, + type AssigneeValue, +} from "@/components/issue/pickers/assignee-picker-sheet"; +import { PriorityPickerSheet } from "@/components/issue/pickers/priority-picker-sheet"; +import { StatusPickerSheet } from "@/components/issue/pickers/status-picker-sheet"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; +import { PriorityIcon } from "@/components/ui/priority-icon"; +import { StatusIcon } from "@/components/ui/status-icon"; +import { useActorLookup } from "@/data/use-actor-name"; +import { PRIORITY_LABEL, STATUS_LABEL } from "@/lib/issue-status"; + +interface Props { + status: IssueStatus; + onStatusChange: (next: IssueStatus) => void; + priority: IssuePriority; + onPriorityChange: (next: IssuePriority) => void; + assignee: AssigneeValue; + onAssigneeChange: (next: AssigneeValue) => void; +} + +export function CreateFormAttributeRow({ + status, + onStatusChange, + priority, + onPriorityChange, + assignee, + onAssigneeChange, +}: Props) { + const [statusOpen, setStatusOpen] = useState(false); + const [priorityOpen, setPriorityOpen] = useState(false); + const [assigneeOpen, setAssigneeOpen] = useState(false); + + const { getName } = useActorLookup(); + const assigneeLabel = assignee + ? getName(assignee.type, assignee.id) + : "Assignee"; + const priorityLabel = + priority === "none" ? "Priority" : PRIORITY_LABEL[priority]; + + return ( + + + } + label={STATUS_LABEL[status]} + variant="filled" + onPress={() => setStatusOpen(true)} + /> + } + label={priorityLabel} + variant={priority === "none" ? "dimmed" : "filled"} + onPress={() => setPriorityOpen(true)} + /> + + ) : ( + + ) + } + label={assigneeLabel} + variant={assignee ? "filled" : "dimmed"} + onPress={() => setAssigneeOpen(true)} + /> + + + setStatusOpen(false)} + /> + setPriorityOpen(false)} + /> + setAssigneeOpen(false)} + /> + + ); +} diff --git a/apps/mobile/components/issue/create-mode-toggle.tsx b/apps/mobile/components/issue/create-mode-toggle.tsx new file mode 100644 index 0000000000..2d020796bd --- /dev/null +++ b/apps/mobile/components/issue/create-mode-toggle.tsx @@ -0,0 +1,70 @@ +/** + * Manual / Agent segmented control for the new-issue modal header. Same + * dichotomy as web's `useCreateModeStore` (Manual = traditional form, + * Agent = natural-language prompt dispatched to an agent). + * + * Rendered via `headerTitle` in the Stack.Screen options, replacing the + * static "New Issue" title — iOS standard for two-mode screens (Mail + * Inbox/VIP, Reminders lists, etc). + * + * Phase 1: mode state lives in local useState. Phase 3 will sync to a + * mobile-local `useCreateModeStore` (mirroring web) so the choice + * persists across modal opens. + */ +import { Pressable, View } from "react-native"; +import { Text } from "@/components/ui/text"; +import { cn } from "@/lib/utils"; + +export type CreateMode = "manual" | "agent"; + +interface Props { + mode: CreateMode; + onChange: (next: CreateMode) => void; +} + +export function CreateModeToggle({ mode, onChange }: Props) { + return ( + + onChange("manual")} + /> + onChange("agent")} + /> + + ); +} + +function Segment({ + label, + active, + onPress, +}: { + label: string; + active: boolean; + onPress: () => void; +}) { + return ( + + + {label} + + + ); +} diff --git a/apps/mobile/components/issue/submit-issue-button.tsx b/apps/mobile/components/issue/submit-issue-button.tsx new file mode 100644 index 0000000000..196d3029e6 --- /dev/null +++ b/apps/mobile/components/issue/submit-issue-button.tsx @@ -0,0 +1,46 @@ +/** + * ↑ submit icon button rendered in the new-issue modal's Stack header + * (headerRight slot). Visually pairs with ModalCloseButton on the left: + * same size/shape circle, brand accent when active vs dimmed disabled + * state. Shows a spinner instead of the arrow while the mutation is + * in-flight, so the user can't double-tap. + */ +import { ActivityIndicator, Pressable, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; +import { cn } from "@/lib/utils"; + +interface Props { + disabled: boolean; + onPress: () => void; + loading?: boolean; +} + +export function SubmitIssueButton({ disabled, onPress, loading }: Props) { + const interactive = !disabled && !loading; + return ( + + + {loading ? ( + + ) : ( + + )} + + + ); +} diff --git a/apps/mobile/components/ui/modal-close-button.tsx b/apps/mobile/components/ui/modal-close-button.tsx index 007aa57743..20daa49685 100644 --- a/apps/mobile/components/ui/modal-close-button.tsx +++ b/apps/mobile/components/ui/modal-close-button.tsx @@ -1,21 +1,25 @@ /** - * Cancel button rendered in the modal Stack header. iOS pattern: a text - * "Cancel" affordance on the leading edge that dismisses the modal. + * Close icon (✕) rendered in the modal Stack header. Matches the iOS + * "close-in-a-circle" pattern used by Linear / Things on mobile create + * sheets — visually pairs with the submit button on the opposite side. * * Used by `[workspace]/_layout.tsx` for the new-issue and search modals. */ -import { Pressable } from "react-native"; +import { Pressable, View } from "react-native"; +import { Ionicons } from "@expo/vector-icons"; import { router } from "expo-router"; -import { Text } from "@/components/ui/text"; export function ModalCloseButton() { return ( router.back()} hitSlop={8} + accessibilityLabel="Close" className="active:opacity-60" > - Cancel + + + ); } diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index d5e0533479..ef09470847 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -16,6 +16,7 @@ import type { Agent, Comment, + CreateIssueRequest, InboxItem, Issue, IssueLabelsResponse, @@ -229,6 +230,17 @@ class ApiClient { return this.fetch(`/api/issues/${id}`); } + // Write endpoint — mirrors POST /api/issues + // (server/cmd/server/router.go:320, server/internal/handler/issue.go + // CreateIssue). Mobile sends only the fields the form fills in; backend + // applies its own defaults for anything omitted. + async createIssue(body: CreateIssueRequest): Promise { + return this.fetch("/api/issues", { + method: "POST", + body: JSON.stringify(body), + }); + } + // V1 only walks "latest → before" (oldest direction). `after` / `around` // are not yet exposed because mobile v1 has no WS push and no notification // deep-link landing target. Mirror of packages/core/api/client.ts diff --git a/apps/mobile/data/mutations/issues.ts b/apps/mobile/data/mutations/issues.ts index 16428910da..e69e11f993 100644 --- a/apps/mobile/data/mutations/issues.ts +++ b/apps/mobile/data/mutations/issues.ts @@ -17,6 +17,7 @@ */ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { + CreateIssueRequest, Issue, IssueReaction, Label, @@ -392,3 +393,26 @@ export function useDetachLabel(issueId: string) { }, }); } + +/** + * Issue creation mutation. No optimistic insert — the my-issues list is + * status-bucketed + scope-filtered (assigned/created/agents), so optimism + * needs to decide which bucket + scope the row lands in, with rollback. + * Invalidation is simpler and the hosted server returns in <300ms. + * + * Invalidates: + * - issueKeys.myAll(wsId) my-issues list (all three scopes) + * - ["inbox", wsId] inbox (assignment notification if any) + */ +export function useCreateIssue() { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationFn: (body: CreateIssueRequest) => api.createIssue(body), + onSuccess: () => { + qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) }); + qc.invalidateQueries({ queryKey: ["inbox", wsId] }); + }, + }); +}