From 3a439d97a1eece6caa4dbaec1362c02fe35bc344 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Thu, 14 May 2026 13:06:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20chat=20v1=20=E2=80=94=20single-?= =?UTF-8?q?tab=20IA,=20optimistic=20send,=20two-tier=20WS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill the Chat tab placeholder. UX is mobile-native (top bar with tap-title sheet, message list, bottom composer — no two-layer nav); logic is at parity with web (API/events/has_unread/optimistic sequence/permissions/ enums all mirrored). Includes: - data layer: 8 chat API methods + zod schemas with .catch() enum drift fallback; queries / mutations (optimistic delete + markRead); per- session drafts store - two-tier realtime: listing-level hook mounted in workspace _layout (chat:session_* + chat:done for has_unread), per-record hook mounted in the chat screen (chat:message/done + 5 task:* events, all filtered by chat_session_id, scoped reconnect invalidates); ws-updaters carry an invalidate fallback for pre-#2123 servers that omit chat:done payload - rule mirrors: canAssignAgent, failureReasonLabel, agent availability three-state hook (mirror-not-import per apps/mobile/CLAUDE.md) - UI: ChatHeader (tap title → SessionSheet) + ChatMessageList (FlatList, destructive bubble on failure_reason) + ChatComposer (mention + markdown toolbar minus file/image) + StatusPill (Thinking · Ns) + SessionSheet (with agent avatars + long-press delete) + AgentPickerSheet + NoAgentBanner v1 cuts (deferred to v2): file upload, rename, Chat tab unread badge, agent presence dot, task tool_use detail expansion, focus mode route anchor, starter prompts, history pagination, mobile test infra. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/(app)/[workspace]/(tabs)/chat.tsx | 402 +++++++++++++++++- apps/mobile/app/(app)/[workspace]/_layout.tsx | 2 + .../components/chat/agent-picker-sheet.tsx | 109 +++++ apps/mobile/components/chat/chat-composer.tsx | 176 ++++++++ apps/mobile/components/chat/chat-header.tsx | 100 +++++ .../components/chat/chat-message-list.tsx | 135 ++++++ .../components/chat/no-agent-banner.tsx | 37 ++ apps/mobile/components/chat/session-sheet.tsx | 158 +++++++ apps/mobile/components/chat/status-pill.tsx | 85 ++++ apps/mobile/data/api.ts | 121 ++++++ apps/mobile/data/mutations/chat.ts | 87 ++++ apps/mobile/data/queries/chat.ts | 52 +++ apps/mobile/data/realtime/chat-ws-updaters.ts | 176 ++++++++ .../realtime/use-chat-session-realtime.ts | 122 ++++++ .../realtime/use-chat-sessions-realtime.ts | 66 +++ apps/mobile/data/schemas.ts | 65 +++ apps/mobile/data/stores/chat-drafts-store.ts | 57 +++ apps/mobile/lib/can-assign-agent.ts | 40 ++ apps/mobile/lib/failure-reason-label.ts | 31 ++ .../lib/workspace-agent-availability.ts | 42 ++ 20 files changed, 2047 insertions(+), 16 deletions(-) create mode 100644 apps/mobile/components/chat/agent-picker-sheet.tsx create mode 100644 apps/mobile/components/chat/chat-composer.tsx create mode 100644 apps/mobile/components/chat/chat-header.tsx create mode 100644 apps/mobile/components/chat/chat-message-list.tsx create mode 100644 apps/mobile/components/chat/no-agent-banner.tsx create mode 100644 apps/mobile/components/chat/session-sheet.tsx create mode 100644 apps/mobile/components/chat/status-pill.tsx create mode 100644 apps/mobile/data/mutations/chat.ts create mode 100644 apps/mobile/data/queries/chat.ts create mode 100644 apps/mobile/data/realtime/chat-ws-updaters.ts create mode 100644 apps/mobile/data/realtime/use-chat-session-realtime.ts create mode 100644 apps/mobile/data/realtime/use-chat-sessions-realtime.ts create mode 100644 apps/mobile/data/stores/chat-drafts-store.ts create mode 100644 apps/mobile/lib/can-assign-agent.ts create mode 100644 apps/mobile/lib/failure-reason-label.ts create mode 100644 apps/mobile/lib/workspace-agent-availability.ts diff --git a/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx b/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx index d82595f03..6f918806d 100644 --- a/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx +++ b/apps/mobile/app/(app)/[workspace]/(tabs)/chat.tsx @@ -1,25 +1,395 @@ /** - * Chat tab (placeholder). + * Chat tab — single-screen IA. * - * Will port the web bottom-right chat widget (chat_sessions resource) - * to a mobile-native page. Logic mirrors web; UI is mobile-native. - * Filled in a later phase. + * Layout: + * SafeAreaView ─ ChatHeader ─ (NoAgentBanner?) ─ ChatMessageList + * └─ StatusPill + * └─ ChatComposer + * + * Session switching, agent selection, and session deletion all happen + * inside this screen via Modal sheets — there is no `/chat/[id]` sub-route. + * + * State (all local, none in Zustand): + * - activeSessionId — which session is being viewed (null = new chat blank) + * - selectedAgentId — overrides currentSession.agent_id when set (used + * when starting a new chat with a freshly-picked agent) + * - sessionSheetOpen — bottom modal visibility + * - agentPickerOpen — bottom modal visibility + * + * Side effects: + * - useChatSessionRealtime(activeSessionId) for per-record WS events + * - auto markRead when entering a session with has_unread + * - ensureSession dedupe ref for concurrent first-message sends + * + * Optimistic send burst mirrors web's chat-window.tsx send sequence + * (packages/views/chat/components/chat-window.tsx ~262-345): + * seed messages → seed pendingTask → flip activeSessionId → POST → + * patch pendingTask with server task_id + created_at. */ -import { View } from "react-native"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Alert, + KeyboardAvoidingView, + Platform, + View, +} from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; -import { Text } from "@/components/ui/text"; -import { ScreenHeader } from "@/components/ui/screen-header"; -import { HeaderActions } from "@/components/ui/app-header-actions"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + Agent, + ChatMessage, + ChatPendingTask, + ChatSession, +} from "@multica/core/types"; +import { api } from "@/data/api"; +import { useAuthStore } from "@/data/auth-store"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { agentListOptions } from "@/data/queries/agents"; +import { memberListOptions } from "@/data/queries/members"; +import { + chatKeys, + chatMessagesOptions, + chatSessionsOptions, + pendingChatTaskOptions, +} from "@/data/queries/chat"; +import { + useCreateChatSession, + useDeleteChatSession, + useMarkChatSessionRead, +} from "@/data/mutations/chat"; +import { + DRAFT_NEW_SESSION, + useChatDraftsStore, +} from "@/data/stores/chat-drafts-store"; +import { useChatSessionRealtime } from "@/data/realtime/use-chat-session-realtime"; +import { canAssignAgent } from "@/lib/can-assign-agent"; +import { useWorkspaceAgentAvailability } from "@/lib/workspace-agent-availability"; +import { ChatHeader } from "@/components/chat/chat-header"; +import { ChatMessageList } from "@/components/chat/chat-message-list"; +import { ChatComposer } from "@/components/chat/chat-composer"; +import { StatusPill } from "@/components/chat/status-pill"; +import { SessionSheet } from "@/components/chat/session-sheet"; +import { AgentPickerSheet } from "@/components/chat/agent-picker-sheet"; +import { NoAgentBanner } from "@/components/chat/no-agent-banner"; + +export default function ChatTab() { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const userId = useAuthStore((s) => s.user?.id); + + const [activeSessionId, setActiveSessionId] = useState(null); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [sessionSheetOpen, setSessionSheetOpen] = useState(false); + const [agentPickerOpen, setAgentPickerOpen] = useState(false); + + // ── Server state ─────────────────────────────────────────────────────── + const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId)); + const { data: agents = [] } = useQuery(agentListOptions(wsId)); + const { data: members = [] } = useQuery(memberListOptions(wsId)); + const { data: messages = [], isLoading: messagesLoading } = useQuery( + chatMessagesOptions(activeSessionId), + ); + const { data: pendingTask } = useQuery( + pendingChatTaskOptions(activeSessionId), + ); + + // ── Derived ──────────────────────────────────────────────────────────── + const memberRole = useMemo( + () => members.find((m) => m.user_id === userId)?.role, + [members, userId], + ); + + const availableAgents = useMemo( + () => + agents.filter( + (a) => !a.archived_at && canAssignAgent(a, userId, memberRole), + ), + [agents, userId, memberRole], + ); + + const activeSession = useMemo( + () => sessions.find((s) => s.id === activeSessionId) ?? null, + [sessions, activeSessionId], + ); + + // Active agent: explicit selection wins; otherwise inherit from the + // active session; otherwise pick the first available agent so a fresh + // workspace lands on the right header rather than "Chat" placeholder. + const currentAgent: Agent | null = useMemo(() => { + if (selectedAgentId) { + return availableAgents.find((a) => a.id === selectedAgentId) ?? null; + } + if (activeSession) { + return agents.find((a) => a.id === activeSession.agent_id) ?? null; + } + return availableAgents[0] ?? null; + }, [selectedAgentId, availableAgents, activeSession, agents]); + + const availability = useWorkspaceAgentAvailability(); + const isArchived = activeSession?.status === "archived"; + const sending = !!pendingTask?.task_id; + + // ── Drafts ───────────────────────────────────────────────────────────── + const draftKey = activeSessionId ?? DRAFT_NEW_SESSION; + const draft = useChatDraftsStore((s) => s.drafts[draftKey] ?? ""); + const setDraft = useChatDraftsStore((s) => s.setDraft); + const clearDraft = useChatDraftsStore((s) => s.clearDraft); + const promoteNewDraft = useChatDraftsStore((s) => s.promoteNewDraft); + + // ── Realtime ─────────────────────────────────────────────────────────── + // Per-record subscription for the active session. If the session is + // deleted by another client, drop the pointer so we land back on the + // new-chat blank state instead of a phantom view. + useChatSessionRealtime(activeSessionId, () => { + setActiveSessionId(null); + }); + + // ── Auto markRead on entering a session with unread state ───────────── + const markRead = useMarkChatSessionRead(); + const lastMarkedRef = useRef(null); + // mutate() triggers an optimistic setQueryData inside onMutate — that's a + // cache write, and writing to a cache this component reads from during + // render breaks React's purity contract (StrictMode in dev fires render + // twice). Always run mutations from an effect. + useEffect(() => { + if (!activeSessionId) return; + if (!activeSession?.has_unread) return; + if (lastMarkedRef.current === activeSessionId) return; + lastMarkedRef.current = activeSessionId; + markRead.mutate(activeSessionId); + }, [activeSessionId, activeSession?.has_unread, markRead]); + + // ── Mutations ────────────────────────────────────────────────────────── + const createSession = useCreateChatSession(); + const deleteSession = useDeleteChatSession(); + + // ── Send burst ───────────────────────────────────────────────────────── + // Ensures a single in-flight createChatSession when the user fires + // multiple sends back-to-back on a new chat. + const sessionPromiseRef = useRef | null>(null); + + const ensureSession = useCallback( + async (titleSeed: string): Promise => { + if (activeSessionId) return activeSessionId; + if (!currentAgent) return null; + if (sessionPromiseRef.current) return sessionPromiseRef.current; + + const promise = (async () => { + try { + const session = await createSession.mutateAsync({ + agent_id: currentAgent.id, + title: titleSeed.slice(0, 50), + }); + return session.id; + } finally { + sessionPromiseRef.current = null; + } + })(); + sessionPromiseRef.current = promise; + return promise; + }, + [activeSessionId, currentAgent, createSession], + ); + + const handleSend = useCallback( + async (content: string) => { + if (!currentAgent) return; + + const isNewSession = !activeSessionId; + const sessionId = await ensureSession(content); + if (!sessionId) return; + + // Optimistic burst — every visual cue lands before the HTTP + // roundtrip so the user sees their message + StatusPill instantly. + const sentAt = new Date().toISOString(); + const optimistic: ChatMessage = { + id: `optimistic-${Date.now()}`, + chat_session_id: sessionId, + role: "user", + content, + task_id: null, + created_at: sentAt, + }; + // Seed messages cache BEFORE flipping activeSessionId so the + // useQuery subscription doesn't render an empty/loading state for + // one frame. + qc.setQueryData(chatKeys.messages(sessionId), (old) => + old ? [...old, optimistic] : [optimistic], + ); + // Seed pendingTask with a temporary id so StatusPill mounts and + // starts ticking immediately. The real task_id arrives below. + qc.setQueryData(chatKeys.pendingTask(sessionId), { + task_id: `optimistic-${optimistic.id}`, + status: "queued", + created_at: sentAt, + }); + if (isNewSession) { + promoteNewDraft(sessionId); + setActiveSessionId(sessionId); + } + + try { + const result = await api.sendChatMessage(sessionId, content); + // Replace the temporary task_id with the server's authoritative + // one + snap created_at so the StatusPill timer doesn't jump. + qc.setQueryData(chatKeys.pendingTask(sessionId), { + task_id: result.task_id, + status: "queued", + created_at: result.created_at, + }); + // Refetch messages to pick up the persisted user message with its + // real id (replacing the `optimistic-*` placeholder). + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + clearDraft(sessionId); + } catch (err) { + // Roll back the optimistic message + pendingTask seed. + qc.setQueryData(chatKeys.messages(sessionId), (old) => + old ? old.filter((m) => m.id !== optimistic.id) : old, + ); + qc.setQueryData(chatKeys.pendingTask(sessionId), {}); + // Re-throw so ChatComposer restores the user's text into the + // input (it catches and calls onChangeText to repopulate). + throw err; + } + }, + [ + activeSessionId, + currentAgent, + ensureSession, + qc, + promoteNewDraft, + clearDraft, + ], + ); + + // ── Cancel in-flight ─────────────────────────────────────────────────── + const handleStop = useCallback(() => { + if (!pendingTask?.task_id || !activeSessionId) return; + // Optimistic clear — pill disappears immediately. WS task:cancelled + // (eventual) will confirm. If the cancel POST fails because the task + // already finished, the success path's WS chat:done already wrote + // the assistant message and there's nothing to recover. + qc.setQueryData(chatKeys.pendingTask(activeSessionId), {}); + void api.cancelTaskById(pendingTask.task_id).catch(() => { + // Silent — task may have already terminated server-side. + }); + }, [pendingTask?.task_id, activeSessionId, qc]); + + // ── Header / sheet actions ───────────────────────────────────────────── + const handleNewChat = useCallback(() => { + // Multi-agent → ask the user. Single-agent or none → just clear the + // active session and let the empty state guide them. + if (availableAgents.length > 1) { + setAgentPickerOpen(true); + return; + } + setSelectedAgentId(null); + setActiveSessionId(null); + }, [availableAgents.length]); + + const handlePickAgent = useCallback((agent: Agent) => { + setSelectedAgentId(agent.id); + setActiveSessionId(null); + }, []); + + const handleSelectSession = useCallback((session: ChatSession) => { + // Clearing selectedAgentId lets currentAgent inherit from the + // session's agent_id (which may differ from what the picker last + // showed). + setSelectedAgentId(null); + setActiveSessionId(session.id); + }, []); + + const handleDeleteActive = useCallback(() => { + if (!activeSession) return; + Alert.alert( + "Delete this chat?", + activeSession.title || "Untitled chat", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => { + const id = activeSession.id; + setActiveSessionId(null); + deleteSession.mutate(id); + }, + }, + ], + { cancelable: true }, + ); + }, [activeSession, deleteSession]); + + const handleDeleteFromSheet = useCallback( + (sessionId: string) => { + if (sessionId === activeSessionId) { + setActiveSessionId(null); + } + deleteSession.mutate(sessionId); + }, + [activeSessionId, deleteSession], + ); + + // ── Composer disabled-state ──────────────────────────────────────────── + const disabled = + !currentAgent || availability === "none" || isArchived === true; + const disabledReason = !currentAgent + ? "No agent selected" + : availability === "none" + ? "No agents in this workspace" + : isArchived + ? "This chat is archived" + : undefined; -export default function Chat() { return ( - - } /> - - - Chat coming soon. - - + + setSessionSheetOpen(true)} + onMorePress={handleDeleteActive} + onNewPress={handleNewChat} + /> + {availability === "none" ? : null} + + + + + + setDraft(draftKey, next)} + onSend={handleSend} + onStop={handleStop} + sending={sending} + disabled={disabled} + disabledReason={disabledReason} + /> + + + setAgentPickerOpen(true)} + onClose={() => setSessionSheetOpen(false)} + /> + setAgentPickerOpen(false)} + /> ); } diff --git a/apps/mobile/app/(app)/[workspace]/_layout.tsx b/apps/mobile/app/(app)/[workspace]/_layout.tsx index 777b214f7..f5b09f8ba 100644 --- a/apps/mobile/app/(app)/[workspace]/_layout.tsx +++ b/apps/mobile/app/(app)/[workspace]/_layout.tsx @@ -6,6 +6,7 @@ import { useWorkspaceStore } from "@/data/workspace-store"; import { RealtimeProvider } from "@/data/realtime/realtime-provider"; import { useInboxRealtime } from "@/data/realtime/use-inbox-realtime"; import { useMyIssuesRealtime } from "@/data/realtime/use-my-issues-realtime"; +import { useChatSessionsRealtime } from "@/data/realtime/use-chat-sessions-realtime"; import { ModalCloseButton } from "@/components/ui/modal-close-button"; /** @@ -20,6 +21,7 @@ import { ModalCloseButton } from "@/components/ui/modal-close-button"; function RealtimeSubscriptions() { useInboxRealtime(); useMyIssuesRealtime(); + useChatSessionsRealtime(); return null; } diff --git a/apps/mobile/components/chat/agent-picker-sheet.tsx b/apps/mobile/components/chat/agent-picker-sheet.tsx new file mode 100644 index 000000000..ac3fff6dd --- /dev/null +++ b/apps/mobile/components/chat/agent-picker-sheet.tsx @@ -0,0 +1,109 @@ +/** + * Agent picker — bottom Modal listing agents the current user can assign / + * chat with. Shown when the user taps `+ New Chat` and the workspace has + * more than one usable agent; with exactly one, the chat screen skips this + * sheet and goes straight to the blank state for that agent. + * + * Filtering is delegated to the caller (the screen passes a pre-filtered + * `agents` list) so the same filter logic — archived + canAssignAgent + + * order — stays in one place. + * + * Layout mirrors `components/issue/my-issues-filter-sheet.tsx`: transparent + * Modal + dimmed backdrop + centered card. Bottom-sheet anchoring would be + * nicer but the current codebase doesn't pull in a bottom-sheet lib and + * centered cards already work well on iOS. + */ +import { Modal, Pressable, ScrollView, View } from "react-native"; +import type { Agent } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; +import { cn } from "@/lib/utils"; + +interface Props { + visible: boolean; + agents: Agent[]; + currentAgentId: string | null; + onPick: (agent: Agent) => void; + onClose: () => void; +} + +export function AgentPickerSheet({ + visible, + agents, + currentAgentId, + onPick, + onClose, +}: Props) { + return ( + + + + {}} className="w-full max-w-sm"> + + + + Choose an agent + + + + + {agents.length === 0 ? ( + + + No agents available. + + + ) : ( + agents.map((agent) => { + const selected = agent.id === currentAgentId; + return ( + { + onPick(agent); + onClose(); + }} + className={cn( + "flex-row items-center gap-3 px-4 py-3 active:bg-secondary", + selected && "bg-secondary/60", + )} + > + + + + {agent.name} + + {agent.description ? ( + + {agent.description} + + ) : null} + + {selected ? ( + + ✓ + + ) : null} + + ); + }) + )} + + + + + + + ); +} diff --git a/apps/mobile/components/chat/chat-composer.tsx b/apps/mobile/components/chat/chat-composer.tsx new file mode 100644 index 000000000..50c713c59 --- /dev/null +++ b/apps/mobile/components/chat/chat-composer.tsx @@ -0,0 +1,176 @@ +/** + * Bottom-sticky chat input. Mirrors the structure of + * `components/issue/comment-composer.tsx` (same mention + markdown toolbar + * + send pattern) but with two chat-specific differences: + * + * 1. No `replyingTo` chip — chat is a flat conversation, not a thread. + * 2. No file/image buttons — v1 cuts file upload (see plan); we wire + * MarkdownToolbar without `onImage` / `onFile` so the buttons hide. + * 3. Send button flips to a Stop button while `sending===true`, giving + * the user a single mid-row affordance for "interrupt the agent". + * + * Draft persistence is delegated to the caller — the chat screen owns + * useChatDraftsStore and feeds `value` + `onChangeText` through here. + * Keeps this component stateless w.r.t. session id (composer doesn't + * need to know which session it's typing into). + */ +import { Pressable, TextInput, View } from "react-native"; +import Svg, { Path } from "react-native-svg"; +import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens"; +import { MarkdownToolbar } from "@/components/editor/markdown-toolbar"; +import { useMentionInput } from "@/lib/use-mention-input"; +import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar"; +import { cn } from "@/lib/utils"; +import { useEffect, useState } from "react"; + +interface Props { + /** Current draft text (controlled). Empty string = no draft. */ + value: string; + /** Fired on every keystroke. The caller writes to the drafts store. */ + onChangeText: (next: string) => void; + /** Send the serialised markdown content. Caller resets the input by + * setting `value=""` after a successful send. */ + onSend: (content: string) => Promise | void; + /** Cancel the in-flight agent task. Only callable while `sending===true`. */ + onStop: () => void; + /** True while an agent task is running for the active session. The + * composer still accepts typing (user can queue the next message) but + * swaps the Send button for a Stop button. */ + sending: boolean; + /** Hard-disable typing + send. Used when there's no usable agent in the + * workspace or the session is archived (legacy). */ + disabled?: boolean; + /** When `disabled` is true, replaces the placeholder with the reason. */ + disabledReason?: string; +} + +export function ChatComposer({ + value, + onChangeText, + onSend, + onStop, + sending, + disabled = false, + disabledReason, +}: Props) { + const mention = useMentionInput(); + const [focused, setFocused] = useState(false); + + // Drive the mention hook from the controlled `value`. When the parent + // resets (post-send) or rehydrates a saved draft (post session-switch), + // sync the internal text. We only push down — onChangeText is the upward + // signal — to avoid an infinite ping-pong loop. + useEffect(() => { + if (mention.text !== value) { + // Reset clears markers + selection, which is correct for both empty + // and full draft hydration. Markers from a different session + // shouldn't carry over. + mention.reset(); + if (value) { + mention.insertAtCursor(value); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- text managed by mention + }, [value]); + + const trimmed = mention.text.trim(); + const canSend = !disabled && !sending && trimmed.length > 0; + + const placeholder = disabled + ? disabledReason ?? "Chat unavailable" + : sending + ? "Agent is working…" + : "Message…"; + + async function handleSend() { + if (!canSend) return; + const content = mention.serialize().trim(); + if (!content) return; + // Optimistic clear — the parent's draft store mirrors `value` and will + // see "" on the next onChangeText; the visual reset is immediate. + onChangeText(""); + mention.reset(); + try { + await onSend(content); + } catch { + // Restore the text so the user doesn't lose what they typed. We push + // through onChangeText so the drafts store gets it too. + onChangeText(content); + } + } + + return ( + + + mention.insertAtLineStart("- ")} + onCheckbox={() => mention.insertAtLineStart("- [ ] ")} + onCode={() => mention.insertAtCursor("\n```\n\n```", 4)} + onQuote={() => mention.insertAtLineStart("> ")} + disabled={disabled || sending} + /> + + + { + mention.handlers.onChangeText(next); + onChangeText(next); + }} + selection={mention.selection} + onSelectionChange={mention.handlers.onSelectionChange} + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + placeholder={placeholder} + placeholderTextColor={MOBILE_PLACEHOLDER_COLOR} + multiline + className="px-4 py-2 text-base text-foreground max-h-32 min-h-8" + editable={!disabled} + /> + + {sending ? ( + + + + ) : canSend ? ( + + + + ) : null} + + + ); +} + +function SendArrow() { + return ( + + + + ); +} diff --git a/apps/mobile/components/chat/chat-header.tsx b/apps/mobile/components/chat/chat-header.tsx new file mode 100644 index 000000000..2be56fbe7 --- /dev/null +++ b/apps/mobile/components/chat/chat-header.tsx @@ -0,0 +1,100 @@ +/** + * Chat screen top bar. + * + * Layout (left-to-right): + * - Tappable centre region: agent avatar + agent name + session title + * subtitle (▼ indicator). Tap → opens session sheet. + * - Right-side actions: ⋯ (current-session menu, only when there IS an + * active session — Delete in v1), + (new chat). + * + * Differs from ScreenHeader (`@/components/ui/screen-header`): the latter + * is left-aligned and doesn't have a press handler on the title. Chat + * needs a centred / tappable title-as-affordance, so this is its own + * component rather than a ScreenHeader variant. + * + * Empty-state copy: when `currentSession === null` (new chat) the + * subtitle reads "New chat" so the title region never looks broken. + */ +import { Pressable, View } from "react-native"; +import type { Agent, ChatSession } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; + +interface Props { + /** Active session — `null` when on the new-chat blank state. */ + currentSession: ChatSession | null; + /** Currently selected agent. May differ from `currentSession.agent_id` for + * one render between agent switch and session reset; the screen reconciles. */ + currentAgent: Agent | null; + onTitlePress: () => void; + onMorePress: () => void; + onNewPress: () => void; +} + +export function ChatHeader({ + currentSession, + currentAgent, + onTitlePress, + onMorePress, + onNewPress, +}: Props) { + const agentName = currentAgent?.name ?? "Chat"; + const subtitle = currentSession?.title || "New chat"; + const showMore = !!currentSession; + + return ( + + + + + + + {agentName} + + + + + {subtitle} + + + + + + {showMore ? ( + + + + ) : null} + + + + + + + ); +} diff --git a/apps/mobile/components/chat/chat-message-list.tsx b/apps/mobile/components/chat/chat-message-list.tsx new file mode 100644 index 000000000..61dab50d2 --- /dev/null +++ b/apps/mobile/components/chat/chat-message-list.tsx @@ -0,0 +1,135 @@ +/** + * Chat message list — user / assistant bubbles, oldest at top, newest at + * bottom. Auto-scrolls to the bottom when the list length increases (new + * message arrived or optimistic send seeded the cache). + * + * Behavioral parity (apps/mobile/CLAUDE.md): + * - Render ALL message roles. Unknown role values are downgraded to + * "assistant" by ChatMessageSchema's `.catch()`, so this list never + * needs to silently drop a row. + * - Render `failure_reason` messages with destructive styling — same + * boolean as web's destructive bubble + failureReasonLabel(). + * + * v1 simplifications: + * - No "Replied in Ns" badge under assistant bubbles (elapsed_ms is + * parsed but not displayed). Easy v2 add — show below the bubble. + * - No attachment card rendering. Attachments embedded as + * `![](url)` / `[name](url)` in `content` flow through the existing + * markdown renderer. See plan-velvety-puddle.md "v2 follow-up". + * + * Layout uses a plain FlatList (mobile baseline — no FlashList — see + * `components/issue/timeline-list.tsx:7`). + */ +import { useEffect, useRef } from "react"; +import { ActivityIndicator, FlatList, View } from "react-native"; +import type { ChatMessage } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { Markdown } from "@/lib/markdown"; +import { failureReasonLabel } from "@/lib/failure-reason-label"; + +interface Props { + messages: ChatMessage[]; + loading: boolean; +} + +export function ChatMessageList({ messages, loading }: Props) { + const listRef = useRef>(null); + const lastLenRef = useRef(messages.length); + + // Auto-scroll to end whenever the list grows. We don't do it on every + // render — that would fight the user's manual scroll-up to read history. + // Assignment runs BEFORE the conditional so the ref tracks actual length + // on every render, not only when the conditional is false. + useEffect(() => { + const grew = messages.length > lastLenRef.current; + lastLenRef.current = messages.length; + if (!grew) return; + // Defer one tick — FlatList content needs to lay out before the + // scroll position is valid. Without the delay, scrollToEnd lands + // on the previous content size. + const id = setTimeout(() => { + listRef.current?.scrollToEnd({ animated: true }); + }, 0); + return () => clearTimeout(id); + }, [messages.length]); + + if (loading && messages.length === 0) { + return ( + + + + ); + } + + if (messages.length === 0) { + // Empty new-chat state. Lives here (rather than the parent screen) so + // the empty state and the rendered list share spacing/layout rules. + return ( + + + Start the conversation. + + + ); + } + + return ( + m.id} + renderItem={({ item }) => } + contentContainerClassName="px-3 py-3 gap-2" + onContentSizeChange={() => { + // Initial mount: jump straight to the bottom without animation so + // the user lands on the latest message, not history. + if (messages.length > 0) { + listRef.current?.scrollToEnd({ animated: false }); + } + }} + /> + ); +} + +function MessageRow({ message }: { message: ChatMessage }) { + const isUser = message.role === "user"; + const isFailure = !!message.failure_reason; + + if (isFailure) { + return ( + + + {failureReasonLabel(message.failure_reason)} + + {message.content ? ( + + {message.content} + + ) : null} + + ); + } + + if (isUser) { + // Plain text for user messages — markdown's MARKDOWN_STYLE colors are + // calibrated for dark-on-light, which renders poorly against a + // primary-colored bubble. Mention serialisation `[MUL-1](mention://…)` + // shows as raw markdown text in the user's own message; this is the + // explicit v1 trade-off (see plan-velvety-puddle.md). Assistant + // messages still go through the rich Markdown pipeline below. + return ( + + + {message.content} + + + ); + } + + // Assistant + return ( + + + + ); +} diff --git a/apps/mobile/components/chat/no-agent-banner.tsx b/apps/mobile/components/chat/no-agent-banner.tsx new file mode 100644 index 000000000..e7d89e0c2 --- /dev/null +++ b/apps/mobile/components/chat/no-agent-banner.tsx @@ -0,0 +1,37 @@ +/** + * Banner shown when the workspace has zero usable agents for the current + * user. Mirrors the role of packages/views/chat/components/no-agent-banner.tsx + * on web — distinct visual cue + a route into the place where users can + * add agents. + * + * Rendered just under ChatHeader. Tap → More → Agents. + */ +import { Pressable } from "react-native"; +import { router } from "expo-router"; +import { Text } from "@/components/ui/text"; +import { useWorkspaceStore } from "@/data/workspace-store"; + +export function NoAgentBanner() { + const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug); + + const handlePress = () => { + if (!wsSlug) return; + router.push(`/${wsSlug}/more/agents`); + }; + + return ( + + + No agents available + + + Add or enable an agent in More → Agents to start chatting. + + + ); +} diff --git a/apps/mobile/components/chat/session-sheet.tsx b/apps/mobile/components/chat/session-sheet.tsx new file mode 100644 index 000000000..fb89e091d --- /dev/null +++ b/apps/mobile/components/chat/session-sheet.tsx @@ -0,0 +1,158 @@ +/** + * Session-switch sheet — opens from the chat header's center title press. + * + * Layout: centered Modal card (same pattern as my-issues-filter-sheet — + * the mobile codebase doesn't use a bottom-sheet lib). + * + * Interactions per row: + * - Tap → switch active session + close sheet + * - Long-press → confirm alert → delete session + * + * Footer row: "Switch agent →" → opens the agent picker sheet. + * + * Archived sessions render in the same flat list with a small "archived" + * label suffix. We don't hide them (parity rule: web shows N sessions → + * mobile shows N sessions). The chat screen disables send for them. + */ +import { Alert, Modal, Pressable, ScrollView, View } from "react-native"; +import type { ChatSession } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; +import { ActorAvatar } from "@/components/ui/actor-avatar"; +import { cn } from "@/lib/utils"; + +interface Props { + visible: boolean; + sessions: ChatSession[]; + activeSessionId: string | null; + onSelectSession: (session: ChatSession) => void; + onDeleteSession: (sessionId: string) => void; + onOpenAgentPicker: () => void; + onClose: () => void; +} + +export function SessionSheet({ + visible, + sessions, + activeSessionId, + onSelectSession, + onDeleteSession, + onOpenAgentPicker, + onClose, +}: Props) { + const confirmDelete = (session: ChatSession) => { + Alert.alert( + "Delete this chat?", + session.title || "Untitled chat", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Delete", + style: "destructive", + onPress: () => onDeleteSession(session.id), + }, + ], + { cancelable: true }, + ); + }; + + return ( + + + + {}} className="w-full max-w-sm"> + + + + Chats + + + + + {sessions.length === 0 ? ( + + + No chats yet. + + + ) : ( + sessions.map((session) => { + const selected = session.id === activeSessionId; + const archived = session.status === "archived"; + return ( + { + onSelectSession(session); + onClose(); + }} + onLongPress={() => confirmDelete(session)} + className={cn( + "flex-row items-center gap-3 px-4 py-3 active:bg-secondary", + selected && "bg-secondary/60", + )} + > + {/* Unread dot — has_unread comes from the server and + * WS chat:done invalidations keep it fresh. Sized + * +reserved-width whether visible or not so the + * avatar column stays aligned across read/unread + * rows. */} + + + + + {session.title || "Untitled chat"} + + {archived ? ( + + archived + + ) : null} + + {selected ? ( + + ✓ + + ) : null} + + ); + }) + )} + + + { + onOpenAgentPicker(); + onClose(); + }} + className="flex-row items-center justify-between px-4 py-3 border-t border-border active:bg-secondary" + > + Switch agent + + + + + + + + ); +} diff --git a/apps/mobile/components/chat/status-pill.tsx b/apps/mobile/components/chat/status-pill.tsx new file mode 100644 index 000000000..9c570d6d8 --- /dev/null +++ b/apps/mobile/components/chat/status-pill.tsx @@ -0,0 +1,85 @@ +/** + * In-flight task status pill — a compact version of web's + * packages/views/chat/components/task-status-pill.tsx. + * + * v1 reads only `ChatPendingTask.status` + `created_at` (no taskMessages + * introspection). Cycle: + * + * status="queued" → "Queued · Ns" + * status="running" → "Thinking · Ns" + * + * Hidden when `pendingTask` is null / empty. The owning chat screen + * decides whether to mount this — there's no internal hide rule beyond + * "show while there's a task id". + * + * Elapsed seconds tick locally via `setInterval(1000)` anchored to + * `pendingTask.created_at` (server-authoritative). 1Hz is enough fidelity + * — sub-second jitter from JS timer drift is invisible at this granularity. + * + * Stop button is the only action — the chat screen wires it to + * api.cancelTaskById. + */ +import { useEffect, useState } from "react"; +import { Pressable, View } from "react-native"; +import type { ChatPendingTask } from "@multica/core/types"; +import { Text } from "@/components/ui/text"; + +interface Props { + pendingTask: ChatPendingTask | null | undefined; + onStop: () => void; +} + +export function StatusPill({ pendingTask, onStop }: Props) { + const taskId = pendingTask?.task_id; + const createdAt = pendingTask?.created_at; + const status = pendingTask?.status; + + // Anchor for the elapsed-seconds counter. Falls back to `Date.now()` when + // the server hasn't sent `created_at` yet (the brief seed window before + // the POST returns). + const anchorMs = createdAtToMs(createdAt) ?? Date.now(); + + // `tickKey` exists only to force a re-render every second; the value + // itself isn't read. setInterval is cheap, and we don't even mount the + // pill when there's no in-flight task. + const [, setTick] = useState(0); + useEffect(() => { + if (!taskId) return; + const id = setInterval(() => setTick((t) => t + 1), 1000); + return () => clearInterval(id); + }, [taskId]); + + if (!taskId) return null; + + const elapsedSec = Math.max(0, Math.floor((Date.now() - anchorMs) / 1000)); + const label = stageLabel(status, elapsedSec); + + return ( + + + {label} + + + + + ); +} + +function stageLabel(status: string | undefined, elapsedSec: number): string { + // Default to "Thinking" — the most common in-flight state. Unknown + // status values from a newer server fall through here rather than + // showing a raw enum string. + if (status === "queued") return `Queued · ${elapsedSec}s`; + return `Thinking · ${elapsedSec}s`; +} + +function createdAtToMs(iso: string | undefined): number | null { + if (!iso) return null; + const ms = Date.parse(iso); + return Number.isFinite(ms) ? ms : null; +} diff --git a/apps/mobile/data/api.ts b/apps/mobile/data/api.ts index 847dbd04d..c6b63d3fb 100644 --- a/apps/mobile/data/api.ts +++ b/apps/mobile/data/api.ts @@ -16,6 +16,9 @@ import type { Agent, Attachment, + ChatMessage, + ChatPendingTask, + ChatSession, Comment, CreateIssueRequest, InboxItem, @@ -28,6 +31,7 @@ import type { ListProjectsResponse, MemberWithUser, Reaction, + SendChatMessageResponse, TimelinePage, UpdateIssueRequest, User, @@ -41,10 +45,18 @@ import { } from "@multica/core/api/schemas"; import { AttachmentSchema, + ChatMessageListSchema, + ChatPendingTaskSchema, + ChatSessionListSchema, + ChatSessionSchema, + EMPTY_CHAT_MESSAGE_LIST, + EMPTY_CHAT_PENDING_TASK, + EMPTY_CHAT_SESSION_LIST, EMPTY_LIST_LABELS_RESPONSE, EMPTY_LIST_PROJECTS_RESPONSE, ListLabelsResponseSchema, ListProjectsResponseSchema, + SendChatMessageResponseSchema, } from "./schemas"; import { getCurrentSlug } from "./workspace-store"; import { parseWithFallback } from "@/lib/parse-response"; @@ -475,6 +487,115 @@ class ApiClient { ); } + // --- Chat --- + // Mirrors the surface area of packages/core/api/client.ts chat methods. + // v1 omits getChatSession + updateChatSession (rename) — see the v1 cut + // list in /Users/qingnaiyuan/.claude/plans/plan-velvety-puddle.md. + + async listChatSessions( + opts?: { signal?: AbortSignal }, + ): Promise { + const raw = await this.fetch("/api/chat/sessions", { + signal: opts?.signal, + }); + return parseWithFallback( + raw, + ChatSessionListSchema, + EMPTY_CHAT_SESSION_LIST, + { endpoint: "GET /api/chat/sessions" }, + ); + } + + async createChatSession( + data: { agent_id: string; title?: string }, + ): Promise { + // Strict parse — a malformed create response derails the optimistic + // burst (we need the new session id to seed caches). Fallback would + // be worse than the throw. + const raw = await this.fetch("/api/chat/sessions", { + method: "POST", + body: JSON.stringify(data), + }); + const parsed = ChatSessionSchema.safeParse(raw); + if (!parsed.success) { + console.error("[api] ← shape mismatch POST /api/chat/sessions", { + issues: parsed.error.issues, + }); + throw new ApiError("Create chat session response invalid", 0, raw); + } + return parsed.data; + } + + async deleteChatSession(id: string): Promise { + await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" }); + } + + async listChatMessages( + sessionId: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const raw = await this.fetch( + `/api/chat/sessions/${sessionId}/messages`, + { signal: opts?.signal }, + ); + return parseWithFallback( + raw, + ChatMessageListSchema, + EMPTY_CHAT_MESSAGE_LIST, + { endpoint: "GET /api/chat/sessions/:id/messages" }, + ); + } + + async sendChatMessage( + sessionId: string, + content: string, + ): Promise { + // Strict parse — we need task_id + created_at to anchor the optimistic + // StatusPill. Fallback would silently break the elapsed-time timer. + const raw = await this.fetch( + `/api/chat/sessions/${sessionId}/messages`, + { + method: "POST", + body: JSON.stringify({ content }), + }, + ); + const parsed = SendChatMessageResponseSchema.safeParse(raw); + if (!parsed.success) { + console.error("[api] ← shape mismatch POST /api/chat/sessions/:id/messages", { + issues: parsed.error.issues, + }); + throw new ApiError("Send message response invalid", 0, raw); + } + return parsed.data; + } + + async getPendingChatTask( + sessionId: string, + opts?: { signal?: AbortSignal }, + ): Promise { + const raw = await this.fetch( + `/api/chat/sessions/${sessionId}/pending-task`, + { signal: opts?.signal }, + ); + return parseWithFallback( + raw, + ChatPendingTaskSchema, + EMPTY_CHAT_PENDING_TASK, + { endpoint: "GET /api/chat/sessions/:id/pending-task" }, + ); + } + + async markChatSessionRead(sessionId: string): Promise { + await this.fetch( + `/api/chat/sessions/${sessionId}/read`, + { method: "POST" }, + ); + } + + async cancelTaskById(taskId: string): Promise { + await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" }); + } + // --- File Upload --- /** diff --git a/apps/mobile/data/mutations/chat.ts b/apps/mobile/data/mutations/chat.ts new file mode 100644 index 000000000..be08cfb36 --- /dev/null +++ b/apps/mobile/data/mutations/chat.ts @@ -0,0 +1,87 @@ +/** + * Mobile chat mutations — create session, delete session, mark session read. + * + * Send-message is NOT a mutation: the chat screen runs a hand-written + * optimistic burst (seed messages cache → seed pendingTask cache → flip + * activeSession → POST → patch with real task_id) that doesn't map cleanly + * onto useMutation. See the chat tab screen for the send path. + * + * Mirrors the optimistic-update + rollback + onSettled-invalidate pattern + * of data/mutations/inbox.ts and web's packages/core/chat/mutations.ts. + */ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ChatSession } from "@multica/core/types"; +import { api } from "@/data/api"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { chatKeys } from "@/data/queries/chat"; + +export function useCreateChatSession() { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationFn: (data: { agent_id: string; title?: string }) => + api.createChatSession(data), + onSettled: () => { + // Optimistic prepend isn't done here — the chat screen seeds caches + // synchronously around its send burst and uses the returned session + // id directly. The invalidate ensures the dropdown picks up the new + // row (and any has_unread / title server defaults) without a refetch + // race on switch. + qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) }); + }, + }); +} + +export function useDeleteChatSession() { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationFn: (id: string) => api.deleteChatSession(id), + onMutate: async (id) => { + const key = chatKeys.sessions(wsId); + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData(key); + qc.setQueryData(key, (old) => + old ? old.filter((s) => s.id !== id) : old, + ); + return { prev, key }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev); + }, + onSettled: (_data, _err, id) => { + qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) }); + // Detail-side caches the screen may still hold for this id. + qc.removeQueries({ queryKey: chatKeys.messages(id) }); + qc.removeQueries({ queryKey: chatKeys.pendingTask(id) }); + }, + }); +} + +export function useMarkChatSessionRead() { + const qc = useQueryClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + + return useMutation({ + mutationFn: (sessionId: string) => api.markChatSessionRead(sessionId), + onMutate: async (sessionId) => { + const key = chatKeys.sessions(wsId); + await qc.cancelQueries({ queryKey: key }); + const prev = qc.getQueryData(key); + qc.setQueryData(key, (old) => + old?.map((s) => + s.id === sessionId ? { ...s, has_unread: false } : s, + ), + ); + return { prev, key }; + }, + onError: (_err, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(ctx.key, ctx.prev); + }, + onSettled: () => { + qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) }); + }, + }); +} diff --git a/apps/mobile/data/queries/chat.ts b/apps/mobile/data/queries/chat.ts new file mode 100644 index 000000000..086b879fd --- /dev/null +++ b/apps/mobile/data/queries/chat.ts @@ -0,0 +1,52 @@ +/** + * Chat query keys + queryOptions factories. + * + * Keys: + * - sessions(wsId) → ChatSession[] for the workspace dropdown / sheet + * - messages(sessionId) → ChatMessage[] for the active session + * - pendingTask(sessionId)→ ChatPendingTask, populated when an agent task is + * in flight; cleared on chat:done / task:cancelled + * + * Same shape as web's `chatKeys` in packages/core/chat/queries.ts (mobile + * owns its own copy per the "mirror, don't import" rule in apps/mobile/CLAUDE.md). + * + * `staleTime: Infinity` everywhere — caches are kept fresh by WS event + * handlers, not by background refetch. Foreground / reconnect invalidates + * are scoped to each owning hook (see use-chat-sessions-realtime.ts and + * use-chat-session-realtime.ts). + */ +import { queryOptions } from "@tanstack/react-query"; +import { api } from "@/data/api"; + +export const chatKeys = { + all: (wsId: string | null) => ["chat", wsId] as const, + sessions: (wsId: string | null) => + [...chatKeys.all(wsId), "sessions"] as const, + messages: (sessionId: string) => ["chat", "messages", sessionId] as const, + pendingTask: (sessionId: string) => + ["chat", "pending-task", sessionId] as const, +}; + +export const chatSessionsOptions = (wsId: string | null) => + queryOptions({ + queryKey: chatKeys.sessions(wsId), + queryFn: ({ signal }) => api.listChatSessions({ signal }), + enabled: !!wsId, + staleTime: Infinity, + }); + +export const chatMessagesOptions = (sessionId: string | null) => + queryOptions({ + queryKey: chatKeys.messages(sessionId ?? ""), + queryFn: ({ signal }) => api.listChatMessages(sessionId!, { signal }), + enabled: !!sessionId, + staleTime: Infinity, + }); + +export const pendingChatTaskOptions = (sessionId: string | null) => + queryOptions({ + queryKey: chatKeys.pendingTask(sessionId ?? ""), + queryFn: ({ signal }) => api.getPendingChatTask(sessionId!, { signal }), + enabled: !!sessionId, + staleTime: Infinity, + }); diff --git a/apps/mobile/data/realtime/chat-ws-updaters.ts b/apps/mobile/data/realtime/chat-ws-updaters.ts new file mode 100644 index 000000000..35e33e6f6 --- /dev/null +++ b/apps/mobile/data/realtime/chat-ws-updaters.ts @@ -0,0 +1,176 @@ +/** + * Mobile-owned WS cache patchers for the chat domain. + * + * Pure functions over QueryClient — no React, no WS plumbing. The + * `use-chat-sessions-realtime` and `use-chat-session-realtime` hooks + * translate WS events into calls into this module. + * + * Why mobile-owned (and not importing from web's chat ws-updaters): + * - Web binds its updaters to `chatKeys` from packages/core/chat/queries.ts, + * a different runtime instance than mobile's data/queries/chat.ts. Keys + * are compared structurally so it'd *appear* to work, but binding cache + * mutation to a foreign key factory invites silent drift the moment + * either side adjusts its key shape. + * - Mobile has a smaller cache surface (no taskMessages live timeline in + * v1, no per-user pending-tasks aggregate). + * + * Cache shapes (the design contract): + * - chatKeys.sessions(wsId) → ChatSession[] + * - chatKeys.messages(sessionId) → ChatMessage[] (flat, ASC oldest→newest) + * - chatKeys.pendingTask(sessionId)→ ChatPendingTask (empty `{}` = no in-flight) + */ +import type { QueryClient } from "@tanstack/react-query"; +import type { + ChatDonePayload, + ChatMessage, + ChatPendingTask, + ChatSession, + ChatSessionDeletedPayload, + TaskQueuedPayload, + TaskDispatchPayload, +} from "@multica/core/types"; +import { chatKeys } from "@/data/queries/chat"; + +// ===================================================== +// Sessions list (ChatSession[] keyed by wsId) +// ===================================================== + +export function patchSessionListAfterRename( + qc: QueryClient, + wsId: string | null, + payload: { + chat_session_id: string; + title?: string; + updated_at?: string; + }, +) { + qc.setQueryData(chatKeys.sessions(wsId), (old) => + old?.map((s) => + s.id === payload.chat_session_id + ? { + ...s, + title: payload.title ?? s.title, + updated_at: payload.updated_at ?? s.updated_at, + } + : s, + ), + ); +} + +export function dropSessionFromList( + qc: QueryClient, + wsId: string | null, + payload: ChatSessionDeletedPayload, +) { + qc.setQueryData(chatKeys.sessions(wsId), (old) => + old?.filter((s) => s.id !== payload.chat_session_id), + ); + qc.removeQueries({ queryKey: chatKeys.messages(payload.chat_session_id) }); + qc.removeQueries({ + queryKey: chatKeys.pendingTask(payload.chat_session_id), + }); +} + +export function flipSessionUnread( + qc: QueryClient, + wsId: string | null, + sessionId: string, + hasUnread: boolean, +) { + qc.setQueryData(chatKeys.sessions(wsId), (old) => + old?.map((s) => + s.id === sessionId ? { ...s, has_unread: hasUnread } : s, + ), + ); +} + +// ===================================================== +// Messages cache (ChatMessage[] keyed by sessionId) +// ===================================================== + +/** + * Apply `chat:done` to the messages cache. + * + * When the payload carries the freshly-persisted assistant message inline + * (message_id + content + created_at), patch the cache directly so the + * assistant bubble lands in the same render tick that clears pendingTask + * — no live-timeline → final-bubble flicker. + * + * Older servers (pre-#2123 in web's commit history) sent only chat_session_id + * + task_id. Detect that and fall back to invalidate; we'll refetch the + * messages list and accept a one-frame window with no bubble. + */ +export function applyChatDoneToCache( + qc: QueryClient, + payload: ChatDonePayload, +) { + if (payload.message_id && payload.content != null && payload.created_at) { + const assistantMsg: ChatMessage = { + id: payload.message_id, + chat_session_id: payload.chat_session_id, + role: "assistant", + content: payload.content, + task_id: payload.task_id, + created_at: payload.created_at, + elapsed_ms: payload.elapsed_ms ?? null, + }; + qc.setQueryData( + chatKeys.messages(payload.chat_session_id), + (old) => { + if (!old) return [assistantMsg]; + // Echo guard — server may re-emit on reconnect. + if (old.some((m) => m.id === assistantMsg.id)) return old; + return [...old, assistantMsg]; + }, + ); + } else { + qc.invalidateQueries({ + queryKey: chatKeys.messages(payload.chat_session_id), + }); + } + // Clear in-flight pointer in the same tick so StatusPill unmounts and + // the AssistantMessage owns the rendering. + qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {}); +} + +// ===================================================== +// Pending task (ChatPendingTask keyed by sessionId) +// ===================================================== + +export function seedPendingTaskFromQueued( + qc: QueryClient, + payload: TaskQueuedPayload, +) { + if (!payload.chat_session_id) return; + qc.setQueryData( + chatKeys.pendingTask(payload.chat_session_id), + (old) => ({ + ...(old ?? {}), + task_id: payload.task_id, + status: "queued", + }), + ); +} + +export function promotePendingTaskToRunning( + qc: QueryClient, + payload: TaskDispatchPayload, +) { + if (!payload.chat_session_id) return; + qc.setQueryData( + chatKeys.pendingTask(payload.chat_session_id), + (old) => { + // Only upgrade if it's the task we already know about. A stale + // dispatch event for a finished task shouldn't reanimate the pill. + if (!old || old.task_id !== payload.task_id) return old; + return { ...old, status: "running" }; + }, + ); +} + +export function clearPendingTask( + qc: QueryClient, + sessionId: string, +) { + qc.setQueryData(chatKeys.pendingTask(sessionId), {}); +} diff --git a/apps/mobile/data/realtime/use-chat-session-realtime.ts b/apps/mobile/data/realtime/use-chat-session-realtime.ts new file mode 100644 index 000000000..b3552bb43 --- /dev/null +++ b/apps/mobile/data/realtime/use-chat-session-realtime.ts @@ -0,0 +1,122 @@ +/** + * Per-session chat realtime — Layer 3. + * + * Mounted by the chat screen with the active session id; cleans up on + * navigate-away. All handlers self-gate on `chat_session_id === sessionId` + * so a backgrounded session (user switched sessions in the sheet but kept + * the chat tab open) doesn't keep mutating caches it no longer owns. + * + * Events handled: + * - chat:message → invalidate messages + pendingTask + * - chat:done → patch messages inline + clear pendingTask + * - task:queued / dispatch → seed / promote pendingTask + * - task:cancelled → clear pendingTask + * - task:completed → no-op for messages (chat:done already + * wrote the assistant message); just + * defensive clear of pendingTask + * - task:failed → clear pendingTask + invalidate messages + * (FailTask persists a failure assistant + * message that must show up) + * - chat:session_deleted → fire onSessionDeleted() so the screen + * can drop the active id and unwind UI + * - reconnect → invalidate this session's messages + + * pendingTask + */ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + ChatDonePayload, + ChatMessageEventPayload, + ChatSessionDeletedPayload, + TaskCancelledPayload, + TaskCompletedPayload, + TaskDispatchPayload, + TaskFailedPayload, + TaskQueuedPayload, +} from "@multica/core/types"; +import { chatKeys } from "@/data/queries/chat"; +import { useWSClient } from "./realtime-provider"; +import { + applyChatDoneToCache, + clearPendingTask, + promotePendingTaskToRunning, + seedPendingTaskFromQueued, +} from "./chat-ws-updaters"; + +export function useChatSessionRealtime( + sessionId: string | null, + onSessionDeleted?: () => void, +) { + const ws = useWSClient(); + const qc = useQueryClient(); + + useEffect(() => { + if (!ws || !sessionId) return; + + const isMine = (p: { chat_session_id?: string }) => + p.chat_session_id === sessionId; + + const invalidateMine = () => { + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) }); + }; + + const unsubs: Array<() => void> = [ + // User-message echo from another device; we may also receive our own + // sends echoed back, but the id-dedupe in the cache write handles + // that. Invalidate is cheap — chat:message is rare in practice. + ws.on("chat:message", (p) => { + const payload = p as ChatMessageEventPayload; + if (!isMine(payload)) return; + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) }); + }), + ws.on("chat:done", (p) => { + const payload = p as ChatDonePayload; + if (!isMine(payload)) return; + applyChatDoneToCache(qc, payload); + }), + ws.on("task:queued", (p) => { + const payload = p as TaskQueuedPayload; + if (!isMine(payload)) return; + seedPendingTaskFromQueued(qc, payload); + }), + ws.on("task:dispatch", (p) => { + const payload = p as TaskDispatchPayload; + if (!isMine(payload)) return; + promotePendingTaskToRunning(qc, payload); + }), + ws.on("task:cancelled", (p) => { + const payload = p as TaskCancelledPayload; + if (!isMine(payload)) return; + clearPendingTask(qc, sessionId); + }), + ws.on("task:completed", (p) => { + const payload = p as TaskCompletedPayload; + if (!isMine(payload)) return; + // `chat:done` already wrote the assistant message and cleared + // pendingTask. Defensive clear in case the two events arrive + // out of order on a flaky network. + clearPendingTask(qc, sessionId); + }), + ws.on("task:failed", (p) => { + const payload = p as TaskFailedPayload; + if (!isMine(payload)) return; + // FailTask persists a destructive assistant message — surface it + // by refetching messages and clearing the pending pill. + clearPendingTask(qc, sessionId); + qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); + }), + ws.on("chat:session_deleted", (p) => { + const payload = p as ChatSessionDeletedPayload; + if (!isMine(payload)) return; + onSessionDeleted?.(); + }), + ws.onReconnect(invalidateMine), + ]; + + return () => { + for (const unsub of unsubs) unsub(); + }; + }, [ws, sessionId, qc, onSessionDeleted]); +} diff --git a/apps/mobile/data/realtime/use-chat-sessions-realtime.ts b/apps/mobile/data/realtime/use-chat-sessions-realtime.ts new file mode 100644 index 000000000..e036d5a8a --- /dev/null +++ b/apps/mobile/data/realtime/use-chat-sessions-realtime.ts @@ -0,0 +1,66 @@ +/** + * Chat sessions list-level realtime — Layer 3. + * + * Mounted globally in workspace `_layout.tsx` via ``. + * Keeps the chatKeys.sessions(wsId) cache fresh regardless of which tab + * the user is on — so when they DO open Chat tab, the dropdown / sheet + * already reflects reality (latest titles, has_unread flags, deletions). + * + * Events handled here are listing-level only — per-session events + * (chat:message, task:*) belong in `use-chat-session-realtime.ts` because + * they target a specific session id known only inside the chat screen. + */ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { + ChatSessionDeletedPayload, +} from "@multica/core/types"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { chatKeys } from "@/data/queries/chat"; +import { useWSClient } from "./realtime-provider"; +import { + dropSessionFromList, + patchSessionListAfterRename, +} from "./chat-ws-updaters"; + +export function useChatSessionsRealtime() { + const ws = useWSClient(); + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const qc = useQueryClient(); + + useEffect(() => { + if (!ws || !wsId) return; + + const invalidateSessions = () => { + qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) }); + }; + + const unsubs: Array<() => void> = [ + // chat:done flips `has_unread` server-side; refetch so the dot shows + // even when the user isn't in the chat screen. + ws.on("chat:done", invalidateSessions), + // chat:session_read clears the unread flag (could be triggered from + // web/desktop on the same account). + ws.on("chat:session_read", invalidateSessions), + // chat:session_updated typically carries the new title — patch the + // cached row inline. + ws.on("chat:session_updated", (p) => { + const payload = p as { + chat_session_id: string; + title?: string; + updated_at?: string; + }; + patchSessionListAfterRename(qc, wsId, payload); + }), + ws.on("chat:session_deleted", (p) => { + dropSessionFromList(qc, wsId, p as ChatSessionDeletedPayload); + }), + // Reconnect: we may have missed events while disconnected. + ws.onReconnect(invalidateSessions), + ]; + + return () => { + for (const unsub of unsubs) unsub(); + }; + }, [ws, wsId, qc]); +} diff --git a/apps/mobile/data/schemas.ts b/apps/mobile/data/schemas.ts index bce82f667..19b69a905 100644 --- a/apps/mobile/data/schemas.ts +++ b/apps/mobile/data/schemas.ts @@ -12,11 +12,15 @@ import { z } from "zod"; import type { Attachment, + ChatMessage, + ChatPendingTask, + ChatSession, IssueLabelsResponse, Label, ListLabelsResponse, ListProjectsResponse, Project, + SendChatMessageResponse, } from "@multica/core/types"; /** Upload response. Only fields mobile actually consumes — `url` to put @@ -95,5 +99,66 @@ export const EMPTY_LIST_PROJECTS_RESPONSE: ListProjectsResponse = { total: 0, }; +// ===================================================== +// Chat (sessions / messages / pending task) +// ===================================================== +// Lenient on every field that's purely informational (status enum, timestamps, +// agent/creator ids). `.loose()` so server-added fields pass through. The two +// fields mobile keys behaviour on — `id` and `chat_session_id` — are required. + +export const ChatSessionSchema: z.ZodType = z.object({ + id: z.string(), + workspace_id: z.string().default(""), + agent_id: z.string().default(""), + creator_id: z.string().default(""), + title: z.string().default(""), + // Enum drift defense (root CLAUDE.md "Enum drift downgrades, not crashes"): + // unknown server values fall back to "active" so the row still renders. + status: z.enum(["active", "archived"]).catch("active"), + has_unread: z.boolean().default(false), + created_at: z.string().default(""), + updated_at: z.string().default(""), +}).loose(); + +export const ChatSessionListSchema = z.array(ChatSessionSchema).default([]); + +export const EMPTY_CHAT_SESSION_LIST: ChatSession[] = []; + +// `attachments` carried for parity rendering only — v1 doesn't author them on +// mobile. AttachmentSchema is reused as-is. +export const ChatMessageSchema: z.ZodType = z.object({ + id: z.string(), + chat_session_id: z.string(), + // If the server ever introduces a third role, fall back to "assistant" so + // the message renders (as a left-aligned bubble) instead of crashing the + // list. Matches Enum drift defense. + role: z.enum(["user", "assistant"]).catch("assistant"), + content: z.string().default(""), + task_id: z.string().nullable().default(null), + created_at: z.string().default(""), + attachments: z.array(AttachmentSchema).optional(), + failure_reason: z.string().nullable().optional(), + elapsed_ms: z.number().nullable().optional(), +}).loose(); + +export const ChatMessageListSchema = z.array(ChatMessageSchema).default([]); + +export const EMPTY_CHAT_MESSAGE_LIST: ChatMessage[] = []; + +// All fields optional — server returns an empty object when no in-flight task. +export const ChatPendingTaskSchema: z.ZodType = z.object({ + task_id: z.string().optional(), + status: z.string().optional(), + created_at: z.string().optional(), +}).loose(); + +export const EMPTY_CHAT_PENDING_TASK: ChatPendingTask = {}; + +export const SendChatMessageResponseSchema: z.ZodType = z.object({ + message_id: z.string(), + task_id: z.string(), + created_at: z.string().default(""), +}).loose(); + // Helpers re-exported for ergonomic single-import at the call site. export type { Label, Project }; diff --git a/apps/mobile/data/stores/chat-drafts-store.ts b/apps/mobile/data/stores/chat-drafts-store.ts new file mode 100644 index 000000000..6f60ed316 --- /dev/null +++ b/apps/mobile/data/stores/chat-drafts-store.ts @@ -0,0 +1,57 @@ +/** + * Per-session chat drafts. In-memory only — drafts survive tab switches and + * navigation, but are lost on app cold start. v1 doesn't persist (an + * SecureStore-backed write on every keystroke would be wasteful; if user + * feedback shows people lose work to backgrounding kills, persist via the + * usual debounced flush pattern in v2). + * + * Key conventions: + * - Real session id (UUID) for any existing session + * - DRAFT_NEW_SESSION sentinel for the not-yet-created new-chat input + */ +import { create } from "zustand"; + +export const DRAFT_NEW_SESSION = "__new__"; + +interface ChatDraftsState { + drafts: Record; + setDraft: (sessionId: string, text: string) => void; + clearDraft: (sessionId: string) => void; + /** Move the `__new__` draft onto a freshly-created session id without + * the user seeing an empty input on the first frame after send. */ + promoteNewDraft: (newSessionId: string) => void; +} + +export const useChatDraftsStore = create((set, get) => ({ + drafts: {}, + setDraft: (sessionId, text) => { + const current = get().drafts; + // Skip the set when the value is identical — Zustand would still emit + // a notification and trigger a re-render of every selector subscriber. + if (current[sessionId] === text) return; + if (text === "") { + // Empty input == no draft; prune so we don't accumulate dead keys. + if (!(sessionId in current)) return; + const next = { ...current }; + delete next[sessionId]; + set({ drafts: next }); + return; + } + set({ drafts: { ...current, [sessionId]: text } }); + }, + clearDraft: (sessionId) => { + const current = get().drafts; + if (!(sessionId in current)) return; + const next = { ...current }; + delete next[sessionId]; + set({ drafts: next }); + }, + promoteNewDraft: (newSessionId) => { + const current = get().drafts; + const pending = current[DRAFT_NEW_SESSION]; + if (!pending) return; + const next = { ...current, [newSessionId]: pending }; + delete next[DRAFT_NEW_SESSION]; + set({ drafts: next }); + }, +})); diff --git a/apps/mobile/lib/can-assign-agent.ts b/apps/mobile/lib/can-assign-agent.ts new file mode 100644 index 000000000..1770e23f7 --- /dev/null +++ b/apps/mobile/lib/can-assign-agent.ts @@ -0,0 +1,40 @@ +/** + * Mobile-owned mirror of the boolean shim + * `packages/views/issues/components/pickers/assignee-picker.tsx:canAssignAgent` + * — which in turn forwards to `packages/core/permissions/rules.ts:canAssignAgentToIssue`. + * + * We mirror (not import) per the apps/mobile/CLAUDE.md sharing rule: only + * `import type` from @multica/core; logic is duplicated to keep mobile + * independent. Any rule change must be applied here too. + * + * Rule (mirrors backend `server/internal/handler/issue.go:1471-1490`): + * - Workspace-visibility agents → assignable by any workspace member + * - Private agents → only owner + workspace admins/owners + * + * Used by the chat agent picker to filter "agents I can talk to" and by + * NoAgentBanner to detect the all-zero state. + */ +import type { Agent } from "@multica/core/types"; + +type MemberRoleLike = "owner" | "admin" | "member" | null | undefined; + +export function canAssignAgent( + agent: Agent, + userId: string | undefined | null, + memberRole: MemberRoleLike, +): boolean { + if (!userId) return false; + + const role: MemberRoleLike = + memberRole === "owner" || memberRole === "admin" || memberRole === "member" + ? memberRole + : null; + + if (agent.visibility === "workspace") { + return role !== null; + } + // visibility === "private" (or anything else — treat unknown as private, + // which is the safer side of an enum drift). + if (role === "owner" || role === "admin") return true; + return agent.owner_id !== null && agent.owner_id === userId; +} diff --git a/apps/mobile/lib/failure-reason-label.ts b/apps/mobile/lib/failure-reason-label.ts new file mode 100644 index 000000000..c204b2707 --- /dev/null +++ b/apps/mobile/lib/failure-reason-label.ts @@ -0,0 +1,31 @@ +/** + * Mirror of `packages/views/agents/components/tabs/task-failure.ts:failureReasonLabel`. + * + * Why mirror: mobile cannot import from packages/views per the apps/mobile + * CLAUDE.md sharing rule. The enum itself comes from packages/core/types + * (type-only import is fine); only the human copy is mobile-owned. + * + * Used by the destructive chat bubble. The default branch handles enum + * drift — unknown values render a generic "Failed" rather than crashing + * or rendering the raw enum string, matching the root CLAUDE.md "Enum + * drift downgrades, not crashes" rule. + */ +import type { TaskFailureReason } from "@multica/core/types"; + +const LABELS: Record = { + agent_error: "Agent execution error", + timeout: "Task timed out", + runtime_offline: "Daemon offline", + runtime_recovery: "Daemon restarted", + manual: "Cancelled by user", +}; + +export function failureReasonLabel( + reason: TaskFailureReason | string | null | undefined, +): string { + if (!reason) return "Failed"; + if (reason in LABELS) { + return LABELS[reason as TaskFailureReason]; + } + return "Failed"; +} diff --git a/apps/mobile/lib/workspace-agent-availability.ts b/apps/mobile/lib/workspace-agent-availability.ts new file mode 100644 index 000000000..88ef4ad8a --- /dev/null +++ b/apps/mobile/lib/workspace-agent-availability.ts @@ -0,0 +1,42 @@ +/** + * Mobile-owned three-state availability for "does the current user have any + * agent they can chat with in this workspace?". + * + * Mirror of `packages/core/agents/use-workspace-agent-availability.ts` — + * see there for the design rationale on why this is a three-state + * `"loading" | "none" | "available"` instead of a boolean. + * + * The chat NoAgentBanner uses this: only `"none"` triggers the banner + + * input-disable; `"loading"` stays neutral to avoid a fake-empty flash on + * mount. + */ +import { useQuery } from "@tanstack/react-query"; +import { useAuthStore } from "@/data/auth-store"; +import { useWorkspaceStore } from "@/data/workspace-store"; +import { agentListOptions } from "@/data/queries/agents"; +import { memberListOptions } from "@/data/queries/members"; +import { canAssignAgent } from "./can-assign-agent"; + +export type WorkspaceAgentAvailability = "loading" | "none" | "available"; + +export function useWorkspaceAgentAvailability(): WorkspaceAgentAvailability { + const wsId = useWorkspaceStore((s) => s.currentWorkspaceId); + const userId = useAuthStore((s) => s.user?.id); + + const { data: agents, isFetched: agentsFetched } = useQuery( + agentListOptions(wsId), + ); + const { data: members, isFetched: membersFetched } = useQuery( + memberListOptions(wsId), + ); + + if (!agentsFetched || !membersFetched) return "loading"; + + const memberRole = members?.find((m) => m.user_id === userId)?.role; + + const hasVisibleAgent = (agents ?? []).some( + (a) => !a.archived_at && canAssignAgent(a, userId, memberRole), + ); + + return hasVisibleAgent ? "available" : "none"; +}