feat(mobile): new issue creation — Manual mode fully wired with @ mention

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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-11 16:33:26 +08:00
parent d23d786724
commit ab1fe4fa1f
7 changed files with 649 additions and 17 deletions

View File

@@ -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<CreateMode>("manual");
// Manual mode fields
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState<IssueStatus>("todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [assignee, setAssignee] = useState<AssigneeValue>(null);
// Mention state (description only — web doesn't allow mentions in title)
const [markers, setMarkers] = useState<MentionMarker[]>([]);
const [selection, setSelection] = useState<{ start: number; end: number }>({
start: 0,
end: 0,
});
const [mentioning, setMentioning] = useState<MentioningState | null>(null);
// Agent mode fields (Phase 3 wires the picker)
const [prompt, setPrompt] = useState("");
const [agentId] = useState<string | null>(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<TextInputSelectionChangeEventData>) => {
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 (
<SubmitIssueButton
disabled={!canSubmit}
loading={isSubmitting}
onPress={onSubmit}
/>
);
}
return HeaderRight;
}, [canSubmit, isSubmitting, onSubmit]);
const headerTitle = useMemo(() => {
function HeaderTitle() {
return <CreateModeToggle mode={mode} onChange={setMode} />;
}
return HeaderTitle;
}, [mode]);
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Text className="text-sm text-muted-foreground text-center">
New issue form coming soon.
</Text>
</View>
<>
<Stack.Screen options={{ headerRight, headerTitle }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{mode === "manual" ? (
<ManualPanel
title={title}
onTitleChange={setTitle}
description={description}
onDescriptionChange={onDescriptionChange}
descriptionSelection={selection}
onDescriptionSelectionChange={onDescriptionSelectionChange}
status={status}
onStatusChange={setStatus}
priority={priority}
onPriorityChange={setPriority}
assignee={assignee}
onAssigneeChange={setAssignee}
mentioning={mentioning}
onSelectMention={onSelectMention}
submitting={isSubmitting}
/>
) : (
<AgentPanel prompt={prompt} onPromptChange={setPrompt} />
)}
</KeyboardAvoidingView>
</>
);
}
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<TextInputSelectionChangeEventData>,
) => 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 (
<>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-2 gap-2"
keyboardShouldPersistTaps="handled"
>
<TextInput
value={title}
onChangeText={onTitleChange}
placeholder="Issue title"
placeholderTextColor="#a1a1aa"
className="text-2xl font-semibold text-foreground py-2"
autoFocus
returnKeyType="next"
editable={!submitting}
/>
<TextInput
value={description}
onChangeText={onDescriptionChange}
selection={descriptionSelection}
onSelectionChange={onDescriptionSelectionChange}
placeholder="Description… (type @ to mention)"
placeholderTextColor="#a1a1aa"
className="text-base text-foreground py-2 min-h-[120px]"
multiline
textAlignVertical="top"
editable={!submitting}
/>
</ScrollView>
<View className="border-t border-border bg-background">
<MentionSuggestionBar
visible={mentioning !== null}
query={mentioning?.query ?? ""}
onSelect={onSelectMention}
/>
<CreateFormAttributeRow
status={status}
onStatusChange={onStatusChange}
priority={priority}
onPriorityChange={onPriorityChange}
assignee={assignee}
onAssigneeChange={onAssigneeChange}
/>
</View>
</>
);
}
function AgentPanel({
prompt,
onPromptChange,
}: {
prompt: string;
onPromptChange: (next: string) => void;
}) {
return (
<>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-2"
keyboardShouldPersistTaps="handled"
>
<TextInput
value={prompt}
onChangeText={onPromptChange}
placeholder="Describe what you want done…"
placeholderTextColor="#a1a1aa"
className="text-base text-foreground py-2 min-h-[160px]"
autoFocus
multiline
textAlignVertical="top"
/>
</ScrollView>
<View className="border-t border-border bg-background px-4 py-3">
{/* Phase 3 will replace this with a real agent picker sheet. */}
<Pressable
onPress={() => {
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}
>
<Ionicons name="sparkles-outline" size={14} color="#a1a1aa" />
<Text className="text-xs text-muted-foreground">
Agent: Select
</Text>
<Ionicons name="chevron-down" size={12} color="#a1a1aa" />
</Pressable>
</View>
</>
);
}

View File

@@ -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 (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="flex-row gap-2 px-4 py-3"
>
<AttributeChip
icon={<StatusIcon status={status} size={12} />}
label={STATUS_LABEL[status]}
variant="filled"
onPress={() => setStatusOpen(true)}
/>
<AttributeChip
icon={<PriorityIcon priority={priority} />}
label={priorityLabel}
variant={priority === "none" ? "dimmed" : "filled"}
onPress={() => setPriorityOpen(true)}
/>
<AttributeChip
icon={
assignee ? (
<ActorAvatar
type={assignee.type}
id={assignee.id}
size={16}
/>
) : (
<Ionicons
name="person-circle-outline"
size={16}
color="#a1a1aa"
/>
)
}
label={assigneeLabel}
variant={assignee ? "filled" : "dimmed"}
onPress={() => setAssigneeOpen(true)}
/>
</ScrollView>
<StatusPickerSheet
visible={statusOpen}
value={status}
onChange={onStatusChange}
onClose={() => setStatusOpen(false)}
/>
<PriorityPickerSheet
visible={priorityOpen}
value={priority}
onChange={onPriorityChange}
onClose={() => setPriorityOpen(false)}
/>
<AssigneePickerSheet
visible={assigneeOpen}
value={assignee}
onChange={onAssigneeChange}
onClose={() => setAssigneeOpen(false)}
/>
</View>
);
}

View File

@@ -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 (
<View className="flex-row rounded-full bg-secondary p-0.5">
<Segment
label="Manual"
active={mode === "manual"}
onPress={() => onChange("manual")}
/>
<Segment
label="Agent"
active={mode === "agent"}
onPress={() => onChange("agent")}
/>
</View>
);
}
function Segment({
label,
active,
onPress,
}: {
label: string;
active: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
hitSlop={4}
className={cn(
"px-3 py-1 rounded-full",
active ? "bg-background shadow-sm" : "active:opacity-60",
)}
>
<Text
className={cn(
"text-xs font-medium",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</Text>
</Pressable>
);
}

View File

@@ -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 (
<Pressable
onPress={interactive ? onPress : undefined}
hitSlop={8}
accessibilityLabel="Create issue"
accessibilityState={{ disabled: !interactive, busy: loading }}
className={cn(interactive && "active:opacity-60")}
>
<View
className={cn(
"size-7 items-center justify-center rounded-full",
interactive ? "bg-brand" : "bg-secondary",
)}
>
{loading ? (
<ActivityIndicator size="small" color="#ffffff" />
) : (
<Ionicons
name="arrow-up"
size={18}
color={interactive ? "#ffffff" : "#a1a1aa"}
/>
)}
</View>
</Pressable>
);
}

View File

@@ -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 (
<Pressable
onPress={() => router.back()}
hitSlop={8}
accessibilityLabel="Close"
className="active:opacity-60"
>
<Text className="text-base text-brand">Cancel</Text>
<View className="size-7 items-center justify-center rounded-full bg-secondary">
<Ionicons name="close" size={18} color="#3f3f46" />
</View>
</Pressable>
);
}

View File

@@ -16,6 +16,7 @@
import type {
Agent,
Comment,
CreateIssueRequest,
InboxItem,
Issue,
IssueLabelsResponse,
@@ -229,6 +230,17 @@ class ApiClient {
return this.fetch<Issue>(`/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<Issue> {
return this.fetch<Issue>("/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

View File

@@ -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] });
},
});
}