diff --git a/apps/mobile/app/(app)/[workspace]/new-issue.tsx b/apps/mobile/app/(app)/[workspace]/new-issue.tsx index 3b31fb3b07..4f99c31dd6 100644 --- a/apps/mobile/app/(app)/[workspace]/new-issue.tsx +++ b/apps/mobile/app/(app)/[workspace]/new-issue.tsx @@ -1,12 +1,9 @@ /** - * New issue creation modal. + * New issue creation modal — manual only. * - * 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). + * title + description + status/priority/assignee/due-date/project chips. + * Fully wired to `apiClient.issues.create` via useCreateIssue(). + * Description supports inline `@mention` (members + agents). * * Mention pipeline is shared with `comment-composer.tsx` via the * `useMentionInput` hook — both surfaces produce the same canonical @@ -21,25 +18,18 @@ import { Alert, KeyboardAvoidingView, Platform, - Pressable, ScrollView, TextInput, View, } 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 { MarkdownToolbar } from "@/components/editor/markdown-toolbar"; import { useFileAttach } from "@/components/editor/use-file-attach"; -import { Text } from "@/components/ui/text"; import { MIN_BODY_INPUT_HEIGHT_PX, MOBILE_PLACEHOLDER_COLOR, @@ -52,68 +42,50 @@ import { } from "@/lib/use-mention-input"; export default function NewIssueModal() { - const [mode, setMode] = useState("manual"); - - // Manual mode fields const [title, setTitle] = useState(""); const description = useMentionInput(); const [status, setStatus] = useState("todo"); const [priority, setPriority] = useState("none"); const [assignee, setAssignee] = useState(null); - - // Agent mode fields (Phase 3 wires the picker) - const [prompt, setPrompt] = useState(""); - const [agentId] = useState(null); + const [dueDate, setDueDate] = useState(null); + const [projectId, setProjectId] = useState(null); const createIssue = useCreateIssue(); const isSubmitting = createIssue.isPending; - const canSubmit = - !isSubmitting && - (mode === "manual" - ? title.trim().length > 0 - : prompt.trim().length > 0); + const canSubmit = !isSubmitting && title.trim().length > 0; const onSubmit = useCallback(async () => { - if (mode === "manual") { - const trimmedTitle = title.trim(); - if (trimmedTitle.length === 0) return; - const finalDescription = description.serialize().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, + const trimmedTitle = title.trim(); + if (trimmedTitle.length === 0) return; + const finalDescription = description.serialize().trim(); + try { + await createIssue.mutateAsync({ + title: trimmedTitle, + description: finalDescription || undefined, + status, + priority, + ...(assignee + ? { assignee_type: assignee.type, assignee_id: assignee.id } + : {}), + ...(dueDate ? { due_date: dueDate } : {}), + ...(projectId ? { project_id: projectId } : {}), }); router.back(); + } catch (err) { + Alert.alert( + "Failed to create issue", + err instanceof Error ? err.message : "Unknown error", + ); } }, [ - mode, title, description, status, priority, assignee, - prompt, - agentId, + dueDate, + projectId, createIssue, ]); @@ -130,36 +102,29 @@ export default function NewIssueModal() { return HeaderRight; }, [canSubmit, isSubmitting, onSubmit]); - const headerTitle = useMemo(() => { - function HeaderTitle() { - return ; - } - return HeaderTitle; - }, [mode]); - return ( <> - + - {mode === "manual" ? ( - - ) : ( - - )} + ); @@ -175,6 +140,10 @@ function ManualPanel({ onPriorityChange, assignee, onAssigneeChange, + dueDate, + onDueDateChange, + projectId, + onProjectIdChange, submitting, }: { title: string; @@ -186,6 +155,10 @@ function ManualPanel({ onPriorityChange: (next: IssuePriority) => void; assignee: AssigneeValue; onAssigneeChange: (next: AssigneeValue) => void; + dueDate: string | null; + onDueDateChange: (next: string | null) => void; + projectId: string | null; + onProjectIdChange: (next: string | null) => void; submitting: boolean; }) { const fileAttach = useFileAttach(); @@ -246,64 +219,16 @@ function ManualPanel({ onPriorityChange={onPriorityChange} assignee={assignee} onAssigneeChange={onAssigneeChange} + dueDate={dueDate} + onDueDateChange={onDueDateChange} + projectId={projectId} + onProjectIdChange={onProjectIdChange} /> ); } -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 - - - - - ); -} - /** Description field with a focus-tinted rounded-2xl container, visually * matching `CommentComposer`'s input so the two "write markdown body" * surfaces feel like the same product. */ diff --git a/apps/mobile/components/issue/create-form-attribute-row.tsx b/apps/mobile/components/issue/create-form-attribute-row.tsx index cf10f9ce14..bee413ac82 100644 --- a/apps/mobile/components/issue/create-form-attribute-row.tsx +++ b/apps/mobile/components/issue/create-form-attribute-row.tsx @@ -3,34 +3,37 @@ * `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 + * - StatusPickerSheet / PriorityPickerSheet / AssigneePickerSheet / + * DueDatePickerSheet / ProjectPickerSheet * - AttributeChip - * - StatusIcon / PriorityIcon / ActorAvatar + * - StatusIcon / PriorityIcon / ActorAvatar / ProjectIcon * * 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"). + * renders `dimmed` with a placeholder label. */ import { useState } from "react"; import { ScrollView, View } from "react-native"; import { Ionicons } from "@expo/vector-icons"; +import { useQuery } from "@tanstack/react-query"; 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 { DueDatePickerSheet } from "@/components/issue/pickers/due-date-picker-sheet"; import { PriorityPickerSheet } from "@/components/issue/pickers/priority-picker-sheet"; +import { ProjectPickerSheet } from "@/components/issue/pickers/project-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 { ProjectIcon } from "@/components/ui/project-icon"; import { StatusIcon } from "@/components/ui/status-icon"; import { useActorLookup } from "@/data/use-actor-name"; +import { projectListOptions } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; import { PRIORITY_LABEL, STATUS_LABEL } from "@/lib/issue-status"; interface Props { @@ -40,6 +43,10 @@ interface Props { onPriorityChange: (next: IssuePriority) => void; assignee: AssigneeValue; onAssigneeChange: (next: AssigneeValue) => void; + dueDate: string | null; + onDueDateChange: (next: string | null) => void; + projectId: string | null; + onProjectIdChange: (next: string | null) => void; } export function CreateFormAttributeRow({ @@ -49,10 +56,16 @@ export function CreateFormAttributeRow({ onPriorityChange, assignee, onAssigneeChange, + dueDate, + onDueDateChange, + projectId, + onProjectIdChange, }: Props) { const [statusOpen, setStatusOpen] = useState(false); const [priorityOpen, setPriorityOpen] = useState(false); const [assigneeOpen, setAssigneeOpen] = useState(false); + const [dueOpen, setDueOpen] = useState(false); + const [projectOpen, setProjectOpen] = useState(false); const { getName } = useActorLookup(); const assigneeLabel = assignee @@ -61,6 +74,15 @@ export function CreateFormAttributeRow({ const priorityLabel = priority === "none" ? "Priority" : PRIORITY_LABEL[priority]; + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { data: projects } = useQuery(projectListOptions(wsId)); + const project = projectId + ? projects?.find((p) => p.id === projectId) + : undefined; + // While the list fetches, show "…" so the filled chip isn't visually + // ambiguous with the unselected "Project" placeholder. + const projectLabel = projectId ? project?.title ?? "…" : "Project"; + return ( setAssigneeOpen(true)} /> + + } + label={dueDate ? formatDueDate(dueDate) : "Due date"} + variant={dueDate ? "filled" : "dimmed"} + onPress={() => setDueOpen(true)} + /> + + ) : ( + + ) + } + label={projectLabel} + variant={projectId ? "filled" : "dimmed"} + onPress={() => setProjectOpen(true)} + /> setAssigneeOpen(false)} /> + setDueOpen(false)} + /> + setProjectOpen(false)} + /> ); } + +function formatDueDate(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "Due date"; + return d.toLocaleDateString(undefined, { month: "short", day: "numeric" }); +} diff --git a/apps/mobile/components/issue/create-mode-toggle.tsx b/apps/mobile/components/issue/create-mode-toggle.tsx deleted file mode 100644 index 2d020796bd..0000000000 --- a/apps/mobile/components/issue/create-mode-toggle.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** - * 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/pickers/due-date-picker-sheet.tsx b/apps/mobile/components/issue/pickers/due-date-picker-sheet.tsx index dca5bdd2c7..b03aed3c6d 100644 --- a/apps/mobile/components/issue/pickers/due-date-picker-sheet.tsx +++ b/apps/mobile/components/issue/pickers/due-date-picker-sheet.tsx @@ -1,11 +1,18 @@ /** * Due-date picker. Wraps `@react-native-community/datetimepicker` (native * UIDatePicker on iOS, Material spinner on Android). Two affordances: - * - "Done" — sends the currently displayed date as ISO date string + * - "Done" — sends the currently displayed date as ISO 8601 / RFC 3339 * - "Clear due date" — sends null (only shown when value is set) * - * Date is stored as ISO date-string (YYYY-MM-DD) on the server. The - * native picker hands us a JS `Date`; we slice the ISO string at the day. + * Backend (`server/internal/handler/issue.go` CreateIssue / UpdateIssue) + * parses with `time.Parse(time.RFC3339, ...)` — strict. Mirrors web's + * `packages/views/issues/components/pickers/due-date-picker.tsx:57` which + * sends `d.toISOString()`. + * + * Note: full ISO means UTC. Users in negative or large positive offsets + * may see a one-day shift after round-trip (e.g. local "May 14" stored as + * "2026-05-13T16:00:00Z" for UTC+8 if backend truncates day). This + * matches web's behavior — diverging here would break parity. */ import { useState, useEffect } from "react"; import { Modal, Pressable, View } from "react-native"; @@ -25,8 +32,8 @@ function isoToDate(iso: string | null): Date { return Number.isNaN(d.getTime()) ? new Date() : d; } -function dateToIsoDay(d: Date): string { - return d.toISOString().slice(0, 10); +function dateToIso(d: Date): string { + return d.toISOString(); } export function DueDatePickerSheet({ @@ -43,7 +50,7 @@ export function DueDatePickerSheet({ }, [visible, value]); const submit = () => { - onChange(dateToIsoDay(draft)); + onChange(dateToIso(draft)); onClose(); }; const clear = () => { diff --git a/apps/mobile/components/issue/pickers/project-picker-sheet.tsx b/apps/mobile/components/issue/pickers/project-picker-sheet.tsx new file mode 100644 index 0000000000..1476c3ec1c --- /dev/null +++ b/apps/mobile/components/issue/pickers/project-picker-sheet.tsx @@ -0,0 +1,157 @@ +/** + * Project picker — single-select over workspace projects. Tap a row to set, + * tap the "No project" row to clear. Mirrors web's `ProjectPicker` + * (`packages/views/projects/components/project-picker.tsx`) in behavior; + * sheet shell is copied from `label-picker-sheet.tsx` and trimmed for + * single-select. + * + * Phase 1 does NOT support inline project creation — mobile users who want + * a new project create it on web. + */ +import { useMemo, useState } from "react"; +import { ActivityIndicator, FlatList, Modal, Pressable, TextInput, View } from "react-native"; +import { useQuery } from "@tanstack/react-query"; +import { Ionicons } from "@expo/vector-icons"; +import type { Project } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ProjectIcon } from "@/components/ui/project-icon"; +import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens"; +import { projectListOptions } from "@/data/queries/projects"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { cn } from "@/lib/utils"; + +interface Props { + visible: boolean; + value: string | null; + onChange: (next: string | null) => void; + onClose: () => void; +} + +export function ProjectPickerSheet({ visible, value, onChange, onClose }: Props) { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const { data: projects, isLoading } = useQuery(projectListOptions(wsId)); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const all = projects ?? []; + const sorted = [...all].sort((a, b) => a.title.localeCompare(b.title)); + if (!q) return sorted; + return sorted.filter((p) => p.title.toLowerCase().includes(q)); + }, [projects, query]); + + const pick = (next: string | null) => { + onChange(next); + onClose(); + }; + + return ( + + + + {}} className="w-full max-w-sm"> + + + + + {isLoading ? ( + + + + ) : ( + item.id} + style={{ maxHeight: 380 }} + ListHeaderComponent={ + pick(null)} + /> + } + renderItem={({ item }) => ( + pick(item.id)} + /> + )} + ListEmptyComponent={ + + + {query + ? "No matches." + : "No projects in this workspace yet.\nCreate them on web."} + + + } + /> + )} + + + + + + ); +} + +function NoProjectRow({ + checked, + onPress, +}: { + checked: boolean; + onPress: () => void; +}) { + return ( + + + No project + {checked ? : null} + + ); +} + +function ProjectRow({ + project, + checked, + onPress, +}: { + project: Project; + checked: boolean; + onPress: () => void; +}) { + return ( + + + + {project.title} + + {checked ? : null} + + ); +} diff --git a/apps/mobile/data/queries/projects.ts b/apps/mobile/data/queries/projects.ts index e715fb87d4..9de9d40eec 100644 --- a/apps/mobile/data/queries/projects.ts +++ b/apps/mobile/data/queries/projects.ts @@ -1,10 +1,6 @@ /** - * Workspace project list. Currently used only to look up `project.title` and - * `project.icon` for the read-only project chip on issue detail. - * - * No project picker yet — defer until web ships one (per plan - * `~/.claude/plans/plan-polymorphic-hickey.md`). When that lands, this same - * query is what the picker would consume. + * Workspace project list. Consumed by the read-only project chip on issue + * detail and by `ProjectPickerSheet` in the new-issue / issue-detail flows. */ import { queryOptions } from "@tanstack/react-query"; import type { Project } from "@multica/core/types";