feat(mobile): chat v1 — single-tab IA, optimistic send, two-tier WS

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) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-14 13:06:47 +08:00
parent a5c5b955df
commit 3a439d97a1
20 changed files with 2047 additions and 16 deletions

View File

@@ -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<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(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<string | null>(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<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
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<ChatMessage[]>(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<ChatPendingTask>(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<ChatPendingTask>(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<ChatMessage[]>(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 (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<ScreenHeader title="Chat" right={<HeaderActions />} />
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
Chat coming soon.
</Text>
</View>
<SafeAreaView
className="flex-1 bg-background"
edges={["top", "bottom"]}
>
<ChatHeader
currentSession={activeSession}
currentAgent={currentAgent}
onTitlePress={() => setSessionSheetOpen(true)}
onMorePress={handleDeleteActive}
onNewPress={handleNewChat}
/>
{availability === "none" ? <NoAgentBanner /> : null}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<View className="flex-1">
<ChatMessageList messages={messages} loading={messagesLoading} />
</View>
<StatusPill pendingTask={pendingTask} onStop={handleStop} />
<ChatComposer
value={draft}
onChangeText={(next) => setDraft(draftKey, next)}
onSend={handleSend}
onStop={handleStop}
sending={sending}
disabled={disabled}
disabledReason={disabledReason}
/>
</KeyboardAvoidingView>
<SessionSheet
visible={sessionSheetOpen}
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteFromSheet}
onOpenAgentPicker={() => setAgentPickerOpen(true)}
onClose={() => setSessionSheetOpen(false)}
/>
<AgentPickerSheet
visible={agentPickerOpen}
agents={availableAgents}
currentAgentId={currentAgent?.id ?? null}
onPick={handlePickAgent}
onClose={() => setAgentPickerOpen(false)}
/>
</SafeAreaView>
);
}

View File

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

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
<View className="px-4 py-3 border-b border-border">
<Text className="text-base font-semibold text-foreground">
Choose an agent
</Text>
</View>
<ScrollView className="max-h-96">
{agents.length === 0 ? (
<View className="px-4 py-8">
<Text className="text-sm text-muted-foreground text-center">
No agents available.
</Text>
</View>
) : (
agents.map((agent) => {
const selected = agent.id === currentAgentId;
return (
<Pressable
key={agent.id}
onPress={() => {
onPick(agent);
onClose();
}}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
selected && "bg-secondary/60",
)}
>
<ActorAvatar type="agent" id={agent.id} size={32} />
<View className="flex-1">
<Text
className="text-sm font-medium text-foreground"
numberOfLines={1}
>
{agent.name}
</Text>
{agent.description ? (
<Text
className="text-xs text-muted-foreground mt-0.5"
numberOfLines={1}
>
{agent.description}
</Text>
) : null}
</View>
{selected ? (
<Text className="text-sm text-primary font-semibold">
</Text>
) : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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> | 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 (
<View className="border-t border-border bg-background">
<MentionSuggestionBar {...mention.suggestionBar} />
<MarkdownToolbar
onAt={mention.handlers.onAtButtonPress}
onList={() => mention.insertAtLineStart("- ")}
onCheckbox={() => mention.insertAtLineStart("- [ ] ")}
onCode={() => mention.insertAtCursor("\n```\n\n```", 4)}
onQuote={() => mention.insertAtLineStart("> ")}
disabled={disabled || sending}
/>
<View className="px-3 py-2 flex-row items-end gap-1.5">
<View
className={cn(
"flex-1 rounded-2xl border",
focused
? "border-primary/30 bg-secondary"
: "border-transparent bg-secondary",
disabled && "opacity-60",
)}
>
<TextInput
value={mention.text}
onChangeText={(next) => {
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}
/>
</View>
{sending ? (
<Pressable
onPress={onStop}
className="h-8 w-8 rounded-full items-center justify-center bg-foreground active:opacity-80"
hitSlop={8}
accessibilityLabel="Stop agent"
>
<View className="h-3 w-3 rounded-sm bg-background" />
</Pressable>
) : canSend ? (
<Pressable
onPress={handleSend}
className="h-8 w-8 rounded-full items-center justify-center bg-primary active:opacity-80"
hitSlop={8}
accessibilityLabel="Send"
>
<SendArrow />
</Pressable>
) : null}
</View>
</View>
);
}
function SendArrow() {
return (
<Svg width={16} height={16} viewBox="0 0 16 16" fill="none">
<Path
d="M8 13V3M8 3l-4 4M8 3l4 4"
stroke="#fff"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
/>
</Svg>
);
}

View File

@@ -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 (
<View className="flex-row items-center px-3 pt-2 pb-2 border-b border-border bg-background">
<Pressable
onPress={onTitlePress}
hitSlop={4}
className="flex-1 flex-row items-center gap-2 px-1 py-1 rounded-lg active:bg-secondary"
accessibilityRole="button"
accessibilityLabel="Sessions and agent picker"
>
<ActorAvatar
type={currentAgent ? "agent" : null}
id={currentAgent?.id ?? null}
size={28}
/>
<View className="flex-1">
<View className="flex-row items-center gap-1">
<Text
className="text-base font-semibold text-foreground"
numberOfLines={1}
>
{agentName}
</Text>
<Text className="text-xs text-muted-foreground"></Text>
</View>
<Text
className="text-xs text-muted-foreground mt-0.5"
numberOfLines={1}
>
{subtitle}
</Text>
</View>
</Pressable>
<View className="flex-row items-center">
{showMore ? (
<Pressable
onPress={onMorePress}
hitSlop={8}
className="h-9 w-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="Session actions"
>
<Text className="text-base text-foreground"></Text>
</Pressable>
) : null}
<Pressable
onPress={onNewPress}
hitSlop={8}
className="h-9 w-9 items-center justify-center rounded-full active:bg-secondary"
accessibilityLabel="New chat"
>
<Text className="text-xl text-foreground leading-none">+</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -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<FlatList<ChatMessage>>(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 (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
);
}
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 (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
Start the conversation.
</Text>
</View>
);
}
return (
<FlatList
ref={listRef}
data={messages}
keyExtractor={(m) => m.id}
renderItem={({ item }) => <MessageRow message={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 (
<View className="self-start max-w-[88%] rounded-2xl border border-destructive/30 bg-destructive/10 px-3 py-2">
<Text className="text-xs font-semibold text-destructive">
{failureReasonLabel(message.failure_reason)}
</Text>
{message.content ? (
<Text className="text-sm text-foreground mt-1" selectable>
{message.content}
</Text>
) : null}
</View>
);
}
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 (
<View className="self-end max-w-[88%] rounded-2xl bg-primary px-3 py-2">
<Text className="text-sm text-primary-foreground" selectable>
{message.content}
</Text>
</View>
);
}
// Assistant
return (
<View className="self-start max-w-[88%]">
<Markdown content={message.content} />
</View>
);
}

View File

@@ -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 (
<Pressable
onPress={handlePress}
className="mx-3 mt-2 mb-1 rounded-xl border border-border bg-secondary/50 px-3 py-2 active:opacity-80"
accessibilityRole="button"
accessibilityLabel="No agents available, open agents settings"
>
<Text className="text-sm font-medium text-foreground">
No agents available
</Text>
<Text className="text-xs text-muted-foreground mt-0.5">
Add or enable an agent in More Agents to start chatting.
</Text>
</Pressable>
);
}

View File

@@ -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 (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/40" onPress={onClose}>
<View className="flex-1 items-center justify-center px-6">
<Pressable onPress={() => {}} className="w-full max-w-sm">
<View className="bg-popover rounded-2xl overflow-hidden">
<View className="px-4 py-3 border-b border-border">
<Text className="text-base font-semibold text-foreground">
Chats
</Text>
</View>
<ScrollView className="max-h-96">
{sessions.length === 0 ? (
<View className="px-4 py-8">
<Text className="text-sm text-muted-foreground text-center">
No chats yet.
</Text>
</View>
) : (
sessions.map((session) => {
const selected = session.id === activeSessionId;
const archived = session.status === "archived";
return (
<Pressable
key={session.id}
onPress={() => {
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. */}
<View
className={cn(
"h-2 w-2 rounded-full",
session.has_unread ? "bg-primary" : "bg-transparent",
)}
/>
<ActorAvatar
type="agent"
id={session.agent_id}
size={32}
/>
<View className="flex-1">
<Text
className={cn(
"text-sm text-foreground",
session.has_unread && "font-semibold",
)}
numberOfLines={1}
>
{session.title || "Untitled chat"}
</Text>
{archived ? (
<Text className="text-xs text-muted-foreground mt-0.5">
archived
</Text>
) : null}
</View>
{selected ? (
<Text className="text-sm text-primary font-semibold">
</Text>
) : null}
</Pressable>
);
})
)}
</ScrollView>
<Pressable
onPress={() => {
onOpenAgentPicker();
onClose();
}}
className="flex-row items-center justify-between px-4 py-3 border-t border-border active:bg-secondary"
>
<Text className="text-sm text-foreground">Switch agent</Text>
<Text className="text-sm text-muted-foreground"></Text>
</Pressable>
</View>
</Pressable>
</View>
</Pressable>
</Modal>
);
}

View File

@@ -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 (
<View className="mx-3 mb-2 flex-row items-center gap-2 rounded-full border border-border bg-secondary px-3 py-1.5">
<View className="h-1.5 w-1.5 rounded-full bg-primary" />
<Text className="flex-1 text-xs text-foreground">{label}</Text>
<Pressable
onPress={onStop}
hitSlop={8}
accessibilityLabel="Stop task"
className="h-6 w-6 items-center justify-center rounded-md active:opacity-70"
>
<View className="h-3 w-3 rounded-sm bg-foreground" />
</Pressable>
</View>
);
}
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;
}

View File

@@ -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<ChatSession[]> {
const raw = await this.fetch<unknown>("/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<ChatSession> {
// 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<unknown>("/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<void> {
await this.fetch<void>(`/api/chat/sessions/${id}`, { method: "DELETE" });
}
async listChatMessages(
sessionId: string,
opts?: { signal?: AbortSignal },
): Promise<ChatMessage[]> {
const raw = await this.fetch<unknown>(
`/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<SendChatMessageResponse> {
// 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<unknown>(
`/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<ChatPendingTask> {
const raw = await this.fetch<unknown>(
`/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<void> {
await this.fetch<void>(
`/api/chat/sessions/${sessionId}/read`,
{ method: "POST" },
);
}
async cancelTaskById(taskId: string): Promise<void> {
await this.fetch<void>(`/api/tasks/${taskId}/cancel`, { method: "POST" });
}
// --- File Upload ---
/**

View File

@@ -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<ChatSession[]>(key);
qc.setQueryData<ChatSession[]>(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<ChatSession[]>(key);
qc.setQueryData<ChatSession[]>(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) });
},
});
}

View File

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

View File

@@ -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<ChatSession[]>(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<ChatSession[]>(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<ChatSession[]>(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<ChatMessage[]>(
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<ChatPendingTask>(
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<ChatPendingTask>(
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), {});
}

View File

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

View File

@@ -0,0 +1,66 @@
/**
* Chat sessions list-level realtime — Layer 3.
*
* Mounted globally in workspace `_layout.tsx` via `<RealtimeSubscriptions />`.
* 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]);
}

View File

@@ -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<ChatSession> = 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<ChatMessage> = 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<ChatPendingTask> = 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<SendChatMessageResponse> = 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 };

View File

@@ -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<string, string>;
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<ChatDraftsState>((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 });
},
}));

View File

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

View File

@@ -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<TaskFailureReason, string> = {
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";
}

View File

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