From ab1fe4fa1feab2cfce6ca7e5f690a98e444902ca Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Mon, 11 May 2026 16:33:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20new=20issue=20creation=20?= =?UTF-8?q?=E2=80=94=20Manual=20mode=20fully=20wired=20with=20@=20mention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile can now actually create issues. Phase 1 left submit as a console.log stub; this iteration wires Manual mode end-to-end so an issue typed on a phone lands in the backend and appears in the user's my-issues list on next refresh. Wire-up: - api.createIssue(body) — POST /api/issues, mirroring server route at server/cmd/server/router.go:320. Matches the CreateIssueRequest type exported from @multica/core/types so payload shape agrees across clients. - useCreateIssue() mutation in data/mutations/issues.ts — no optimistic insert (the my-issues list is status-bucketed + scope-filtered, so optimism needs bucket+scope decisions; invalidation is simpler and hosted-backend latency is sub-300ms). onSuccess invalidates myAll and inbox query keys. - new-issue.tsx Manual panel: submit ↑ calls mutateAsync, dismisses on success, surfaces errors via Alert.alert with the form state preserved so the user can retry. Button shows a spinner during the in-flight request and all inputs are disabled. @ mention in description (members + agents): - Mirrors comment-composer.tsx pattern exactly — selection tracking, tokenAtCursor on every change/selection event, MentionSuggestionBar rendered above the chip row, insertMention on pick, markers list appended. - Title input stays plain (web doesn't allow mentions in title; we mirror that). - Wire format on submit: serializeMentions(description, markers) → `[@name](mention://type/id)` markdown. Recognised by: * server/internal/util/mention.go ParseMentions * packages/views/editor/extensions/mention-extension.ts (web Tiptap) * apps/mobile/components/issue/mention-chip.tsx (mobile timeline) - Backend does NOT trigger inbox notifications for mentions in issue descriptions (only on comments — see server/internal/handler/comment.go ParseMentions call). Mobile doesn't need to send a separate mentioned_* field; the markdown alone is sufficient. Header polish: - SubmitIssueButton accepts a `loading` prop; renders ActivityIndicator in place of the ↑ glyph while pending. Defends against double-tap. - ModalCloseButton's earlier "Cancel" text is now a ✕ icon in a circle to match the new-issue / search modal visual reference (Linear-style). Agent mode unchanged — still a placeholder that console.logs and dismisses. Phase 3 will wire the real agent picker, apiClient .quickCreateIssue, and the daemon version gate. Explicitly NOT in this commit (later phases): - Markdown formatting toolbar (Phase 2C) - Project / Labels / Due date / Parent chips (Phase 2D) - Image / file attachments (Phase 2E) - #MUL-42 issue references, @all mention - Draft persistence, "Create Another" toggle - Pre-fill from sub-issue entry, optimistic list insert - Success toast (success path = silent dismiss; mobile has no toast component yet) Verified: pnpm --filter @multica/mobile typecheck passes; lint shows only pre-existing issues unrelated to this change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/(app)/[workspace]/new-issue.tsx | 375 +++++++++++++++++- .../issue/create-form-attribute-row.tsx | 125 ++++++ .../components/issue/create-mode-toggle.tsx | 70 ++++ .../components/issue/submit-issue-button.tsx | 46 +++ .../components/ui/modal-close-button.tsx | 14 +- apps/mobile/data/api.ts | 12 + apps/mobile/data/mutations/issues.ts | 24 ++ 7 files changed, 649 insertions(+), 17 deletions(-) create mode 100644 apps/mobile/components/issue/create-form-attribute-row.tsx create mode 100644 apps/mobile/components/issue/create-mode-toggle.tsx create mode 100644 apps/mobile/components/issue/submit-issue-button.tsx 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] }); + }, + }); +}