feat(mobile): add due_date / project to create-issue, drop agent toggle

Wire the last two CreateIssueRequest fields that have a meaningful UX on
mobile (due_date, project_id) to the new-issue form via two new chips
sharing the existing CreateFormAttributeRow + picker-sheet pattern.

Fixes a silent 400 on the existing detail-page due_date update: the
picker was emitting YYYY-MM-DD but server/internal/handler/issue.go
parses with time.Parse(time.RFC3339, ...) which rejects date-only. Now
sends full ISO, matching web's due-date-picker.tsx.

Removes the placeholder agent-mode toggle from new-issue — it was a
dead UI surface (logged to console on submit, never wired). Mobile's
create-issue is now manual-only, aligned with web's form semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-14 13:21:48 +08:00
parent 3a439d97a1
commit da1c7d5bc2
6 changed files with 300 additions and 221 deletions

View File

@@ -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<CreateMode>("manual");
// Manual mode fields
const [title, setTitle] = useState("");
const description = useMentionInput();
const [status, setStatus] = useState<IssueStatus>("todo");
const [priority, setPriority] = useState<IssuePriority>("none");
const [assignee, setAssignee] = useState<AssigneeValue>(null);
// Agent mode fields (Phase 3 wires the picker)
const [prompt, setPrompt] = useState("");
const [agentId] = useState<string | null>(null);
const [dueDate, setDueDate] = useState<string | null>(null);
const [projectId, setProjectId] = 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 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 <CreateModeToggle mode={mode} onChange={setMode} />;
}
return HeaderTitle;
}, [mode]);
return (
<>
<Stack.Screen options={{ headerRight, headerTitle }} />
<Stack.Screen options={{ headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
{mode === "manual" ? (
<ManualPanel
title={title}
onTitleChange={setTitle}
description={description}
status={status}
onStatusChange={setStatus}
priority={priority}
onPriorityChange={setPriority}
assignee={assignee}
onAssigneeChange={setAssignee}
submitting={isSubmitting}
/>
) : (
<AgentPanel prompt={prompt} onPromptChange={setPrompt} />
)}
<ManualPanel
title={title}
onTitleChange={setTitle}
description={description}
status={status}
onStatusChange={setStatus}
priority={priority}
onPriorityChange={setPriority}
assignee={assignee}
onAssigneeChange={setAssignee}
dueDate={dueDate}
onDueDateChange={setDueDate}
projectId={projectId}
onProjectIdChange={setProjectId}
submitting={isSubmitting}
/>
</KeyboardAvoidingView>
</>
);
@@ -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}
/>
</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={MOBILE_PLACEHOLDER_COLOR}
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={MOBILE_PLACEHOLDER_COLOR}
/>
<Text className="text-xs text-muted-foreground">Agent: Select</Text>
<Ionicons
name="chevron-down"
size={12}
color={MOBILE_PLACEHOLDER_COLOR}
/>
</Pressable>
</View>
</>
);
}
/** 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. */

View File

@@ -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 (
<View>
<ScrollView
@@ -100,6 +122,30 @@ export function CreateFormAttributeRow({
variant={assignee ? "filled" : "dimmed"}
onPress={() => setAssigneeOpen(true)}
/>
<AttributeChip
icon={
<Ionicons
name="calendar-outline"
size={14}
color={dueDate ? undefined : "#a1a1aa"}
/>
}
label={dueDate ? formatDueDate(dueDate) : "Due date"}
variant={dueDate ? "filled" : "dimmed"}
onPress={() => setDueOpen(true)}
/>
<AttributeChip
icon={
project ? (
<ProjectIcon icon={project.icon} size="sm" />
) : (
<Ionicons name="folder-outline" size={14} color="#a1a1aa" />
)
}
label={projectLabel}
variant={projectId ? "filled" : "dimmed"}
onPress={() => setProjectOpen(true)}
/>
</ScrollView>
<StatusPickerSheet
@@ -120,6 +166,24 @@ export function CreateFormAttributeRow({
onChange={onAssigneeChange}
onClose={() => setAssigneeOpen(false)}
/>
<DueDatePickerSheet
visible={dueOpen}
value={dueDate}
onChange={onDueDateChange}
onClose={() => setDueOpen(false)}
/>
<ProjectPickerSheet
visible={projectOpen}
value={projectId}
onChange={onProjectIdChange}
onClose={() => setProjectOpen(false)}
/>
</View>
);
}
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" });
}

View File

@@ -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 (
<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

@@ -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 = () => {

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
<View className="px-3 pt-3 pb-2 border-b border-border">
<TextInput
value={query}
onChangeText={setQuery}
placeholder="Search projects"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-sm text-foreground bg-secondary/50 rounded-md px-3 py-2"
autoCapitalize="none"
autoCorrect={false}
/>
</View>
{isLoading ? (
<View className="px-3 py-8 items-center">
<ActivityIndicator />
</View>
) : (
<FlatList
data={filtered}
keyExtractor={(item) => item.id}
style={{ maxHeight: 380 }}
ListHeaderComponent={
<NoProjectRow
checked={value === null}
onPress={() => pick(null)}
/>
}
renderItem={({ item }) => (
<ProjectRow
project={item}
checked={item.id === value}
onPress={() => pick(item.id)}
/>
)}
ListEmptyComponent={
<View className="px-3 py-6 items-center">
<Text className="text-xs text-muted-foreground text-center">
{query
? "No matches."
: "No projects in this workspace yet.\nCreate them on web."}
</Text>
</View>
}
/>
)}
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}
function NoProjectRow({
checked,
onPress,
}: {
checked: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
"flex-row items-center gap-3 px-3 py-2.5 border-b border-border active:bg-secondary",
checked && "bg-secondary",
)}
>
<Ionicons name="close-circle-outline" size={16} color={MOBILE_PLACEHOLDER_COLOR} />
<Text className="flex-1 text-sm text-muted-foreground">No project</Text>
{checked ? <Text className="text-xs text-muted-foreground"></Text> : null}
</Pressable>
);
}
function ProjectRow({
project,
checked,
onPress,
}: {
project: Project;
checked: boolean;
onPress: () => void;
}) {
return (
<Pressable
onPress={onPress}
className={cn(
"flex-row items-center gap-3 px-3 py-2.5 active:bg-secondary",
checked && "bg-secondary",
)}
>
<ProjectIcon icon={project.icon} size="md" />
<Text className="flex-1 text-sm text-foreground" numberOfLines={1}>
{project.title}
</Text>
{checked ? <Text className="text-xs text-muted-foreground"></Text> : null}
</Pressable>
);
}

View File

@@ -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";