mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
125
apps/mobile/components/issue/create-form-attribute-row.tsx
Normal file
125
apps/mobile/components/issue/create-form-attribute-row.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
apps/mobile/components/issue/create-mode-toggle.tsx
Normal file
70
apps/mobile/components/issue/create-mode-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
apps/mobile/components/issue/submit-issue-button.tsx
Normal file
46
apps/mobile/components/issue/submit-issue-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user