mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
109
apps/mobile/components/chat/agent-picker-sheet.tsx
Normal file
109
apps/mobile/components/chat/agent-picker-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
apps/mobile/components/chat/chat-composer.tsx
Normal file
176
apps/mobile/components/chat/chat-composer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
apps/mobile/components/chat/chat-header.tsx
Normal file
100
apps/mobile/components/chat/chat-header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
apps/mobile/components/chat/chat-message-list.tsx
Normal file
135
apps/mobile/components/chat/chat-message-list.tsx
Normal 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
|
||||
* `` / `[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>
|
||||
);
|
||||
}
|
||||
37
apps/mobile/components/chat/no-agent-banner.tsx
Normal file
37
apps/mobile/components/chat/no-agent-banner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
apps/mobile/components/chat/session-sheet.tsx
Normal file
158
apps/mobile/components/chat/session-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/mobile/components/chat/status-pill.tsx
Normal file
85
apps/mobile/components/chat/status-pill.tsx
Normal 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;
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
/**
|
||||
|
||||
87
apps/mobile/data/mutations/chat.ts
Normal file
87
apps/mobile/data/mutations/chat.ts
Normal 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
52
apps/mobile/data/queries/chat.ts
Normal file
52
apps/mobile/data/queries/chat.ts
Normal 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,
|
||||
});
|
||||
176
apps/mobile/data/realtime/chat-ws-updaters.ts
Normal file
176
apps/mobile/data/realtime/chat-ws-updaters.ts
Normal 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), {});
|
||||
}
|
||||
122
apps/mobile/data/realtime/use-chat-session-realtime.ts
Normal file
122
apps/mobile/data/realtime/use-chat-session-realtime.ts
Normal 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]);
|
||||
}
|
||||
66
apps/mobile/data/realtime/use-chat-sessions-realtime.ts
Normal file
66
apps/mobile/data/realtime/use-chat-sessions-realtime.ts
Normal 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]);
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
57
apps/mobile/data/stores/chat-drafts-store.ts
Normal file
57
apps/mobile/data/stores/chat-drafts-store.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
40
apps/mobile/lib/can-assign-agent.ts
Normal file
40
apps/mobile/lib/can-assign-agent.ts
Normal 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;
|
||||
}
|
||||
31
apps/mobile/lib/failure-reason-label.ts
Normal file
31
apps/mobile/lib/failure-reason-label.ts
Normal 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";
|
||||
}
|
||||
42
apps/mobile/lib/workspace-agent-availability.ts
Normal file
42
apps/mobile/lib/workspace-agent-availability.ts
Normal 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";
|
||||
}
|
||||
Reference in New Issue
Block a user