"use client"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { motion } from "motion/react"; import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check, Archive, Pencil, Loader2, Square } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { cn } from "@multica/ui/lib/utils"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger, } from "@multica/ui/components/ui/popover"; import { toast } from "sonner"; import { useWorkspaceId } from "@multica/core/hooks"; import { useAuthStore } from "@multica/core/auth"; import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; import { canAssignAgent } from "@multica/views/issues/components"; import { api } from "@multica/core/api"; import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents"; import { useEditorUpload } from "../../editor"; import { ActorAvatar } from "../../common/actor-avatar"; import { useAppForeground } from "../../common/use-app-foreground"; import { PickerEmpty, PickerItem, PickerSection, PropertyPicker, } from "../../issues/components/pickers/property-picker"; import { matchesPinyin } from "../../editor/extensions/pinyin-match"; import { OfflineBanner } from "./offline-banner"; import { NoAgentBanner } from "./no-agent-banner"; import { ArchivedAgentBanner } from "./archived-agent-banner"; import { chatSessionsOptions, chatMessagesPageOptions, pendingChatTaskOptions, pendingChatTasksOptions, chatKeys, isTaskMessageTaskId, } from "@multica/core/chat/queries"; import { useCreateChatSession, useMarkChatSessionRead, useSetChatSessionArchived, useSetChatSessionProject, useUpdateChatSession, } from "@multica/core/chat/mutations"; import { useChatStore } from "@multica/core/chat"; import { removeChatMessageFromCaches } from "@multica/core/realtime"; import { useChatDraftRestore } from "./use-chat-draft-restore"; import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list"; import { ChatInput } from "./chat-input"; import { ChatResizeHandles } from "./chat-resize-handles"; import { useChatContextItems } from "./use-chat-context-items"; import { useChatResize } from "./use-chat-resize"; import { hasOptimisticInFlight, isStillOnComposeTarget, planProjectContextChange, } from "./use-chat-controller"; import { useChatProjectContextSupport } from "./use-chat-project-context-support"; import { createLogger } from "@multica/core/logger"; import type { Agent, Attachment, ChatMessage, ChatMessagesPage, ChatPendingTask, ChatSession, PendingChatTasksResponse } from "@multica/core/types"; import { useT } from "../../i18n"; const uiLogger = createLogger("chat.ui"); const apiLogger = createLogger("chat.api"); const CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX = 1_000_000; function appendChatMessageToLatestPageCache( qc: ReturnType, sessionId: string, message: ChatMessage, ) { qc.setQueryData>( chatKeys.messagesPage(sessionId), (old) => { if (!old) { return { pages: [{ messages: [message], limit: 50, has_more: false, next_cursor: null, }], pageParams: [null], }; } if (old.pages.some((page) => page.messages.some((m) => m.id === message.id))) { return old; } return { ...old, pages: old.pages.map((page, index) => index === 0 ? { ...page, messages: [...page.messages, message] } : page, ), }; }, ); } function replaceOptimisticChatMessageId( qc: ReturnType, sessionId: string, optimisticId: string, messageId: string, taskId: string, ) { const replace = (messages: ChatMessage[] | undefined) => { if (!messages) return messages; if (messages.some((m) => m.id === messageId)) { return messages.filter((m) => m.id !== optimisticId); } return messages.map((m) => m.id === optimisticId ? { ...m, id: messageId, task_id: taskId } : m, ); }; qc.setQueryData( chatKeys.messages(sessionId), replace, ); qc.setQueryData | undefined>( chatKeys.messagesPage(sessionId), (old) => { if (!old) return old; return { ...old, pages: old.pages.map((page) => ({ ...page, messages: replace(page.messages) ?? page.messages, })), }; }, ); } export function ChatWindow() { const { t } = useT("chat"); const wsId = useWorkspaceId(); const isOpen = useChatStore((s) => s.isOpen); const activeSessionId = useChatStore((s) => s.activeSessionId); const selectedAgentId = useChatStore((s) => s.selectedAgentId); const selectedProjectId = useChatStore((s) => s.selectedProjectId); const setOpen = useChatStore((s) => s.setOpen); const setActiveSession = useChatStore((s) => s.setActiveSession); const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId); const setSelectedProjectId = useChatStore((s) => s.setSelectedProjectId); const user = useAuthStore((s) => s.user); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { data: members = [] } = useQuery(memberListOptions(wsId)); // Single sessions cache — eliminates the separate active/all queries // that used to drift during the WS-invalidate window. const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery( chatSessionsOptions(wsId), ); const { data: projects = [], isSuccess: projectsLoaded } = useQuery( projectListOptions(wsId), ); const { data: rawMessagePages, isLoading: messagesLoading, fetchNextPage: fetchOlderMessages, hasNextPage: hasOlderMessages, isFetchingNextPage: isFetchingOlderMessages, } = useInfiniteQuery(chatMessagesPageOptions(activeSessionId ?? "")); // When no active session, always show empty — don't use stale cache. // Page 0 contains the latest chronological window; later cursor pages are // older chronological windows. Reverse pages so older fetched pages render // above the initial latest page. The Virtuoso firstItemIndex is client-owned: // it starts from a large stable base and only subtracts the count of loaded // prepended rows, so concurrent server inserts cannot drift the scroll anchor. const messagePages = activeSessionId ? rawMessagePages?.pages ?? [] : []; const messages = [...messagePages].reverse().flatMap((page) => page.messages); const olderMessageCount = messagePages.slice(1).reduce((sum, page) => sum + page.messages.length, 0); const firstItemIndex = messages.length > 0 ? CHAT_VIRTUOSO_INITIAL_FIRST_ITEM_INDEX - olderMessageCount : 0; // Skeleton only shows for an un-cached session fetch. Cached switches // return data synchronously — no flash. `enabled: false` (new chat) // keeps isLoading false so the starter prompts aren't hidden. const showSkeleton = !!activeSessionId && messagesLoading; // Server-authoritative pending task. Survives refresh / reopen / session // switch because it's keyed on sessionId in the Query cache; WS events // (chat:message / chat:done / task:*) keep it invalidated in real time. // // This is the SOLE source for pendingTaskId — no mirror in the store. const { data: pendingTask } = useQuery( pendingChatTaskOptions(activeSessionId ?? ""), ); const pendingTaskId = pendingTask?.task_id ?? null; const stopRequestedBeforeTaskRef = useRef(false); // Durable deferred-cancellation draft restores (#5219). Same hook as the chat // page controller — the skip/apply/consume/reconcile state machine must not // diverge between the two composers. // // Gated on isOpen AND app foreground: this window stays MOUNTED when closed // (it is only hidden, see isVisible below), and isOpen alone is also true for a // backgrounded browser tab. Its ChatInput would otherwise adopt and consume a // restore with nobody looking at it — stealing the prompt from the composer the // user is actually waiting on, possibly on another device. Only a composer the // user can actually see claims; a re-foregrounded window recovers on its next // fetch. (appForeground also gates auto mark-read below.) const appForeground = useAppForeground(); const { restoreDraftRequest, enqueueLocalRestore, handleRestoreDraftApplied } = useChatDraftRestore(activeSessionId, isOpen && appForeground); // Nonce handed to ChatInput to pull focus into the compose box when a new // chat starts (⊕ or switching agent). 0 is inert so opening the window on an // existing session never steals focus. const [focusRequest, setFocusRequest] = useState(0); const requestInputFocus = useCallback( () => setFocusRequest((n) => n + 1), [], ); // Legacy archived sessions (the old soft-archive feature was removed but // pre-existing rows with status='archived' may still exist) are excluded // from the history dropdown. If one is still the active session, ChatInput // is disabled and the server still rejects POST /messages for it. const currentSession = activeSessionId ? sessions.find((s) => s.id === activeSessionId) : null; const isSessionArchived = currentSession?.status === "archived"; const candidateProjectId = currentSession ? currentSession.project_id ?? null : selectedProjectId; const activeProjectId = candidateProjectId && (!projectsLoaded || projects.some((project) => project.id === candidateProjectId)) ? candidateProjectId : null; useEffect(() => { if (!projectsLoaded || !selectedProjectId) return; if (projects.some((project) => project.id === selectedProjectId)) return; setSelectedProjectId(null); }, [projectsLoaded, projects, selectedProjectId, setSelectedProjectId]); const qc = useQueryClient(); const createSession = useCreateChatSession(); const markRead = useMarkChatSessionRead(); const setSessionProject = useSetChatSessionProject(); const currentMember = members.find((m) => m.user_id === user?.id); const memberRole = currentMember?.role; const availableAgents = agents.filter( (a) => !a.archived_at && canAssignAgent(a, user?.id, memberRole), ); // The agent bound to the OPEN session, resolved from the full agent list // (archived included). An archived agent is filtered out of availableAgents, // so resolving only from that list would make an archived-agent session // render some *other* available agent — wrong avatar/name and a send that // targets the wrong agent. Binding to the session's real agent keeps it // honest; the archived state then makes the conversation read-only. const sessionAgent = currentSession ? agents.find((a) => a.id === currentSession.agent_id) ?? null : null; const isAgentArchived = !!sessionAgent?.archived_at; // Resolve selected agent: open session's agent → stored preference → first // available. New chats have no session, so they fall through to the picker. const activeAgent = sessionAgent ?? availableAgents.find((a) => a.id === selectedAgentId) ?? availableAgents[0] ?? null; const projectContextSupport = useChatProjectContextSupport(wsId, activeAgent); // Three-state availability — "loading" stays neutral (no banner, no // disable) so the input doesn't flash a fake "no agent" state in the // few hundred ms before the agent list query resolves. Only `"none"` // (server confirmed: zero usable agents) drives the disabled UI. const agentAvailability = useWorkspaceAgentAvailability(); const noAgent = agentAvailability === "none"; // Presence drives both the avatar status dot (via ActorAvatar) and the // OfflineBanner / TaskStatusPill availability copy. `useAgentPresenceDetail` // returns "loading" while queries are still resolving — pass `undefined` // downstream so banners and pill copy stay silent during loading rather // than flash speculative offline text. const presenceDetail = useAgentPresenceDetail(wsId, activeAgent?.id); const availability = presenceDetail === "loading" ? undefined : presenceDetail.availability; // Mount / unmount logging. ChatWindow lives in DashboardLayout, so this // fires on layout mount (login / workspace switch / fresh page load). useEffect(() => { uiLogger.info("ChatWindow mount", { isOpen, activeSessionId, pendingTaskId, selectedAgentId, wsId, }); return () => { uiLogger.info("ChatWindow unmount", { activeSessionId, pendingTaskId, }); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- once per mount }, []); // Self-heal a dangling `activeSessionId` (persisted / restored from storage) // that points at a session which was deleted or lost access: once the // sessions list has loaded and doesn't contain it — with no in-flight // optimistic write exempting a just-created session — clear it so the // floating window shows the new-chat state instead of an editable empty chat // whose send would POST into a nonexistent session. Same fix the shared // controller applies for the tab (kept in sync via `hasOptimisticInFlight`). // The earlier "no self-heal" note was about a naive version keyed on stale // `allSessions`; the optimistic-write signal here exempts the freshly-created // session (handleSend seeds its optimistic message + pending task BEFORE // setActiveSession), so it is never mistaken for stale. useEffect(() => { if (!activeSessionId || !sessionsLoaded) return; if (sessions.some((s) => s.id === activeSessionId)) return; if (hasOptimisticInFlight(qc, activeSessionId)) return; uiLogger.info("clearing dangling activeSessionId (floating)", { sessionId: activeSessionId }); setActiveSession(null); }, [activeSessionId, sessionsLoaded, sessions, qc, setActiveSession]); // WS events are handled globally in useRealtimeSync — the query cache // stays current even when this window is closed. See packages/core/realtime/. // Auto mark-as-read whenever the user is looking at a session with unread // state: window open + app in the foreground + a session active + has_unread // → PATCH. has_unread comes from the list query; WS handlers invalidate it on // chat:done so a reply arriving while the user watches triggers this effect // again and is instantly cleared. `appForeground` gates the "is looking" // assumption: a reply landing while the window is open but the app is // backgrounded must stay unread so the sidebar badges it (MUL-4485), then // clears when the user refocuses and this effect re-runs. const currentHasUnread = sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false; useEffect(() => { if (!isOpen || !appForeground || !activeSessionId) return; if (!currentHasUnread) return; uiLogger.info("auto markRead", { sessionId: activeSessionId }); markRead.mutate(activeSessionId); // eslint-disable-next-line react-hooks/exhaustive-deps -- markRead ref stable }, [isOpen, appForeground, activeSessionId, currentHasUnread]); const { uploadWithToast } = useEditorUpload(); // Lazy-creates a chat_session the first time the user needs an id — // either to send a message or to attach an uploaded file. Pulled out of // handleSend so the upload path (which fires before any text exists) can // get a session_id to hang the attachment on. Returns null when no agent // is available; callers must early-return in that case. // // Concurrent callers (e.g. user drops a file → handleUploadFile, then // quickly clicks send → handleSend) would each observe activeSessionId // === null and fire a separate createSession.mutateAsync, creating two // sessions and orphaning the attachment on the wrong one. The in-flight // promise ref dedupes those races: the first caller starts the create, // every subsequent caller awaits the same promise until it settles. // // titleSeed is the first 50 chars of the user's message when called from // send; the upload path passes "" and we leave the title empty so the // session-dropdown's existing localized `window.untitled` fallback kicks // in. A follow-up task may back-fill the real title from the first user // message — until then this keeps the session list scannable across locales. // // NOTE: ensureSession does NOT flip `activeSessionId` itself. Callers must // seed `chatKeys.messages(sessionId)` in the Query cache BEFORE calling // `setActiveSession(sessionId)`, otherwise the first useQuery subscription // for the new key reports `isLoading: true` and renders ChatMessageSkeleton // for one frame (the "new-chat first-message" white flash). const sessionPromiseRef = useRef | null>(null); const ensureSession = useCallback( async (titleSeed: string): Promise => { // Trust the current id only when it's real: in the loaded list, or a // just-created one still awaiting the refetch (has an optimistic write). // A dangling id (deleted / no access) must not be treated as an existing // session — fall through and create a fresh one instead of POSTing 404. if ( activeSessionId && (!sessionsLoaded || sessions.some((s) => s.id === activeSessionId) || hasOptimisticInFlight(qc, activeSessionId)) ) { return activeSessionId; } if (!activeAgent) return null; if (sessionPromiseRef.current) return sessionPromiseRef.current; const promise = (async () => { try { const session = await createSession.mutateAsync({ agent_id: activeAgent.id, title: titleSeed.slice(0, 50), project_id: activeProjectId, }); return session.id; } finally { sessionPromiseRef.current = null; } })(); sessionPromiseRef.current = promise; return promise; }, [ activeSessionId, activeAgent, activeProjectId, createSession, sessions, sessionsLoaded, qc, ], ); const handleUploadFile = useCallback( async (file: File) => { if (!activeAgent) return null; // Uploads are workspace-scoped drafts. Sending the message is the point // where we create a chat session (if needed) and bind attachment_ids to // the persisted chat_message row. This keeps a paste/drop from creating // an empty chat session the user never sends. return uploadWithToast(file); }, [activeAgent, uploadWithToast], ); const cancelChatTask = useCallback( async ( taskId: string, sessionId: string, options: { restoreDraftToInput: boolean; source: string }, ) => { apiLogger.info("cancelTask.start", { taskId, sessionId, source: options.source, }); qc.setQueryData(chatKeys.pendingTask(sessionId), {}); try { const result = await api.cancelTaskById(taskId); const restored = result.cancelled_chat_message; if (restored?.restore_to_input) { removeChatMessageFromCaches(qc, restored.chat_session_id, restored.message_id); if (options.restoreDraftToInput && restored.chat_session_id === sessionId) { enqueueLocalRestore({ id: restored.message_id, content: restored.content, attachments: restored.attachments, sessionId: restored.chat_session_id, }); } } qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); apiLogger.info("cancelTask.success", { taskId, sessionId, restoredToInput: !!restored?.restore_to_input && options.restoreDraftToInput, }); return result; } catch (err) { apiLogger.warn("cancelTask.error (task may have already finished)", { taskId, sessionId, err, }); qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); return null; } }, [qc, enqueueLocalRestore], ); const handleSend = useCallback( async ( content: string, attachmentIds?: string[], commitInput?: (options?: { extraDraftKeys?: string[]; clearEditor?: boolean }) => void, draftAttachments: Attachment[] = [], ): Promise => { if (!activeAgent) { apiLogger.warn("sendChatMessage skipped: no active agent"); return false; } // Read-only conversation: the agent is retired and can no longer pick up // work, so refuse to enqueue a task that would sit orphaned forever. The // input is disabled in this state; this is the belt-and-braces guard. if (isAgentArchived) { apiLogger.warn("sendChatMessage skipped: agent is archived", { sessionId: activeSessionId, agentId: activeAgent.id, }); return false; } const finalContent = content; const isNewSession = !activeSessionId; apiLogger.info("sendChatMessage.start", { sessionId: activeSessionId, isNewSession, agentId: activeAgent.id, contentLength: finalContent.length, attachmentCount: attachmentIds?.length ?? 0, }); let sessionId: string | null = null; try { sessionId = await ensureSession(finalContent); } catch (err) { apiLogger.error("sendChatMessage.ensureSession.error", err); toast.error(t(($) => $.input.send_failed_toast)); return false; } if (!sessionId) { apiLogger.warn("sendChatMessage aborted: ensureSession returned null"); return false; } // Optimistic burst — everything that gives the user "I sent a message // and the agent is now working" feedback fires BEFORE the HTTP roundtrip. // Pre-#status-pill the pending-task seed lived after `await // sendChatMessage` and the pill blinked in a few hundred ms after the // user's message — small but visible "did it actually send?" gap. const sentAt = new Date().toISOString(); const optimistic: ChatMessage = { id: `optimistic-${Date.now()}`, chat_session_id: sessionId, role: "user", content: finalContent, task_id: null, created_at: sentAt, attachments: draftAttachments, }; // Seed cache BEFORE flipping activeSessionId. If we set the active // session first, useQuery's first subscription to the new key sees no // cached data and renders ChatMessageSkeleton for one frame — the // "new-chat first-message" white flash. Priming the cache first means // the very first read after activeSessionId flips hits data // synchronously and ChatMessageList mounts directly. appendChatMessageToLatestPageCache(qc, sessionId, optimistic); qc.setQueryData( chatKeys.messages(sessionId), (old) => (old ? [...old, optimistic] : [optimistic]), ); // Seed the pending-task with a temporary id so the StatusPill mounts // and starts ticking the instant the user clicks send. Real task_id // and server-authoritative created_at land below; until then the pill // is anchored to the local clock (drift is the request RTT, ~50–200ms, // which doesn't change the rendered "Ns" value). qc.setQueryData(chatKeys.pendingTask(sessionId), { task_id: `optimistic-${optimistic.id}`, status: "queued", created_at: sentAt, }); // Cache primed → safe to publish the new active session, but only if the // user hasn't navigated away mid-send. Compare the live store against the // closure-captured target; see isStillOnComposeTarget for the rule, which // this floating window shares with the chat tab's controller. const live = useChatStore.getState(); const stillOnSourceSession = isStillOnComposeTarget(live.activeSessionId, activeSessionId); if (stillOnSourceSession) { setActiveSession(sessionId); } commitInput?.({ extraDraftKeys: [sessionId], clearEditor: stillOnSourceSession }); apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id }); let result; try { result = await api.sendChatMessage(sessionId, finalContent, attachmentIds); } catch (err) { apiLogger.error("sendChatMessage.error.rollback", { sessionId, optimisticId: optimistic.id, err }); stopRequestedBeforeTaskRef.current = false; removeChatMessageFromCaches(qc, sessionId, optimistic.id); qc.setQueryData(chatKeys.pendingTask(sessionId), {}); enqueueLocalRestore({ id: `send-failed-${optimistic.id}`, content: finalContent, attachments: draftAttachments, // Restore into the session this was sent from. If the user navigated // away (fire-and-forget) the request waits in that session's persisted // queue until they return, rather than dumping content into another // session or dying with this component. sessionId, }); toast.error(t(($) => $.input.send_failed_toast)); return false; } apiLogger.info("sendChatMessage.success", { sessionId, messageId: result.message_id, taskId: result.task_id, }); replaceOptimisticChatMessageId(qc, sessionId, optimistic.id, result.message_id, result.task_id); // Replace the temporary task_id with the server's real one (so the WS // task: handlers can match against it) and snap the anchor to the // server's created_at — keeping the elapsed-seconds reading stable. qc.setQueryData(chatKeys.pendingTask(sessionId), { task_id: result.task_id, status: "queued", created_at: result.created_at, }); if (stopRequestedBeforeTaskRef.current) { stopRequestedBeforeTaskRef.current = false; await cancelChatTask(result.task_id, sessionId, { restoreDraftToInput: true, source: "deferred-send", }); return false; } // The server reports which attachment ids it actually bound. Diff // against what we requested so a silent bind failure surfaces to the // user — no extra fetch. Skip the check on servers that predate the // field (attachment_ids undefined) rather than false-alarm. if (attachmentIds && attachmentIds.length > 0 && result.attachment_ids) { const boundIds = new Set(result.attachment_ids); const missing = attachmentIds.filter((id) => !boundIds.has(id)); if (missing.length > 0) { apiLogger.warn("sendChatMessage.attachments missing after send", { sessionId, messageId: result.message_id, missing, }); toast.error(t(($) => $.input.attachment_bind_failed_toast)); } } qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) }); qc.invalidateQueries({ queryKey: chatKeys.messagesPage(sessionId) }); return true; }, [ activeSessionId, activeAgent, isAgentArchived, ensureSession, cancelChatTask, qc, setActiveSession, enqueueLocalRestore, t, ], ); const handleStop = useCallback(() => { if (!pendingTaskId || !activeSessionId) { apiLogger.debug("cancelTask skipped: no pending task"); return; } if (!isTaskMessageTaskId(pendingTaskId)) { stopRequestedBeforeTaskRef.current = true; apiLogger.info("cancelTask.deferred until server task id", { taskId: pendingTaskId, sessionId: activeSessionId, }); return; } void cancelChatTask(pendingTaskId, activeSessionId, { restoreDraftToInput: true, source: "active-input", }); }, [pendingTaskId, activeSessionId, cancelChatTask]); const handleSelectAgent = useCallback( (agent: Agent) => { // No-op when clicking the already-active agent — don't clobber the // current session just because the user closed the menu this way. // Compare against activeAgent (what the UI shows), not selectedAgentId // (which may be null / point to an archived agent on first load). if (activeAgent && agent.id === activeAgent.id) return; uiLogger.info("selectAgent", { from: selectedAgentId, to: agent.id, previousSessionId: activeSessionId, }); setSelectedAgentId(agent.id); // Preserve an explicitly chosen project while composing an unsent chat, // but never inherit project context from the historical session being // left behind. setSelectedProjectId(currentSession ? null : activeProjectId); // Reset session when switching agent setActiveSession(null); requestInputFocus(); }, [ activeAgent, selectedAgentId, activeSessionId, activeProjectId, currentSession, setSelectedAgentId, setSelectedProjectId, setActiveSession, requestInputFocus, ], ); const handleNewChat = useCallback(() => { uiLogger.info("newChat", { previousSessionId: activeSessionId, previousPendingTask: pendingTaskId, }); setSelectedProjectId(null); setActiveSession(null); requestInputFocus(); }, [ activeSessionId, pendingTaskId, setSelectedProjectId, setActiveSession, requestInputFocus, ]); const handleSelectSession = useCallback( (session: ChatSession) => { // Sessions are bound 1:1 to an agent — picking a session from a // different agent implicitly switches the agent too. if (activeAgent && session.agent_id !== activeAgent.id) { uiLogger.info("selectSession (cross-agent)", { from: activeAgent.id, toAgent: session.agent_id, toSession: session.id, }); setSelectedAgentId(session.agent_id); } setActiveSession(session.id); }, [activeAgent, setSelectedAgentId, setActiveSession], ); const handleProjectChange = useCallback( (projectId: string | null) => { if (projectId === activeProjectId) return; uiLogger.info("selectProjectContext", { from: activeProjectId, to: projectId, previousSessionId: activeSessionId, }); const plan = planProjectContextChange({ targetProjectId: projectId, activeSessionId, currentSession: currentSession ?? null, }); switch (plan.kind) { case "awaitSession": return; case "detachCurrent": setSessionProject.mutate({ sessionId: plan.sessionId, projectId: null }); break; case "startFreshChat": setSelectedAgentId(plan.agentId); setSelectedProjectId(plan.projectId); setActiveSession(null); break; case "setDraftProject": setSelectedProjectId(plan.projectId); break; } requestInputFocus(); }, [ activeProjectId, activeSessionId, currentSession, setSessionProject, setSelectedAgentId, setSelectedProjectId, setActiveSession, requestInputFocus, ], ); const handleMinimize = useCallback(() => { uiLogger.info("minimize (close)", { activeSessionId, pendingTaskId, }); setOpen(false); }, [activeSessionId, pendingTaskId, setOpen]); const isExpanded = useChatStore((s) => s.isExpanded); const windowRef = useRef(null); const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef); // Show the list (vs empty state) as soon as there's anything to display — // a real message, or a pending task whose timeline will stream in. const hasMessages = messages.length > 0 || !!pendingTaskId; const isVisible = isOpen && (isExpanded || boundsReady); const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col overflow-hidden rounded-xl bg-surface-raised shadow-[var(--floating-shadow)] ring-1 ring-surface-border"; const containerStyle: React.CSSProperties = { transformOrigin: "bottom right", pointerEvents: isOpen ? "auto" : "none", }; const contextItems = useChatContextItems(wsId); return ( {/* Header — ⊕ new + session dropdown | window tools */}
} > {t(($) => $.window.new_chat_tooltip)}
} > {isExpanded || isAtMax ? : } {isExpanded || isAtMax ? t(($) => $.window.restore_tooltip) : t(($) => $.window.expand_tooltip)} } > {t(($) => $.window.minimize_tooltip)}
{/* Messages / skeleton / empty state */} {showSkeleton ? ( ) : hasMessages ? ( void fetchOlderMessages()} /> ) : ( 0} agentName={activeAgent?.name} onPickPrompt={(text) => handleSend(text)} /> )} {/* Status banner above the input — single mutually-exclusive slot. * Priority: no-agent > offline / unstable. Agent presence is the * hard prerequisite (you can't send anything without one), so it * always wins over a presence hint. Recent issue/project navigation * lives in the input action row; it is not message/session state. * * We key off `noAgent` (the resolved-empty state) rather than * `!activeAgent`, so the loading window between mount and the * first agent-list response stays banner-free. */} {noAgent ? ( ) : isAgentArchived ? ( ) : ( )} {/* Input — disabled for legacy archived sessions and for sessions whose * agent has been archived (read-only); locked out entirely when there's * no agent (the EmptyState above carries the CTA). */} } contextItems={contextItems} focusRequest={focusRequest} />
); } /** * Agent dropdown: avatar trigger, lists all available agents. Selecting a * different agent = switch agent + start a fresh chat (session=null). * The current agent is marked with a check and not clickable. */ export function AgentDropdown({ agents, activeAgent, userId, onSelect, }: { agents: Agent[]; activeAgent: Agent | null; userId: string | undefined; onSelect: (agent: Agent) => void; }) { const { t } = useT("chat"); const [open, setOpen] = useState(false); const [filter, setFilter] = useState(""); // Split into the user's own agents and everyone else so the menu groups // them — matches the old AgentSelector layout. const { mine, others } = useMemo(() => { const mine: Agent[] = []; const others: Agent[] = []; for (const a of agents) { if (a.owner_id === userId) mine.push(a); else others.push(a); } return { mine, others }; }, [agents, userId]); const query = filter.trim().toLowerCase(); const matches = (name: string) => !query || name.toLowerCase().includes(query) || matchesPinyin(name, query); const filteredMine = mine.filter((agent) => matches(agent.name)); const filteredOthers = others.filter((agent) => matches(agent.name)); const handlePick = (agent: Agent) => { onSelect(agent); setOpen(false); }; if (!activeAgent) { return {t(($) => $.window.no_agents)}; } return ( $.window.agent_filter_placeholder)} onSearchChange={setFilter} triggerRender={ ) : (
{isRunning && } {showCompleted && !isRunning && } {showUnread && !isRunning && !showCompleted && ( $.window.unread)} title={t(($) => $.window.unread)} className="size-1.5 rounded-full bg-brand" /> )} {trailingStatus}
{isRunning && pendingTask && ( )} {!isRunning && ( <> )}
) )} ); }; return ( <>
{triggerAgent && ( )} {title} {currentSessionRunning && ( $.session_history.row_subtitle.working)} className="size-3 shrink-0 animate-spin text-muted-foreground" /> )} {otherRunningCount > 0 ? ( $.window.another_running)} title={t(($) => $.window.another_running)} className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground" > {otherRunningCount > 1 && {otherRunningCount}} ) : otherUnreadCount > 0 ? ( $.window.another_unread)} title={t(($) => $.window.another_unread)} className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-muted-foreground" > {otherUnreadCount > 1 && {otherUnreadCount}} ) : null}
e.stopPropagation()} > {historySessions.length === 0 ? (
{t(($) => $.window.no_previous)}
) : (
$.window.history_group)}>
{t(($) => $.window.history_group)}
{historySessions.map(renderRow)}
)}
); } /** * Inline editor for a session title. Mounts focused with the existing * title pre-selected so the user can either replace it outright or arrow * into the existing text. Enter commits, Escape cancels, a real click * outside the input also commits. * * We do NOT commit on the input's `blur` event: the history popover can * move focus to sibling rows and nested actions while the user is still * interacting with the panel. Instead a document-level `pointerdown` * listener commits only when the user actually clicks outside the input. */ function SessionRenameInput({ initialValue, onSubmit, onCancel, }: { initialValue: string; onSubmit: (value: string) => void; onCancel: () => void; }) { const { t } = useT("chat"); const [value, setValue] = useState(initialValue); const inputRef = useRef(null); // Hold the latest value + callback in refs so the mount-only effect's // listener always sees fresh state without re-subscribing on every // keystroke (which would briefly leave a window where pointerdown isn't // observed). const valueRef = useRef(value); valueRef.current = value; const onSubmitRef = useRef(onSubmit); onSubmitRef.current = onSubmit; useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); const handlePointerDown = (e: PointerEvent) => { const input = inputRef.current; if (!input) return; if (input.contains(e.target as Node)) return; onSubmitRef.current(valueRef.current); }; // Capture phase — commit before outside-click handling can close the // popover and unmount this component. document.addEventListener("pointerdown", handlePointerDown, true); return () => { document.removeEventListener("pointerdown", handlePointerDown, true); }; }, []); return ( $.session_history.row_rename_aria)} onChange={(e) => setValue(e.target.value)} onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()} onKeyDown={(e) => { // Keep editing keys inside the input instead of letting the row // selection keyboard handler consume them. e.stopPropagation(); if (e.key === "Enter") { e.preventDefault(); onSubmit(value); } else if (e.key === "Escape") { e.preventDefault(); onCancel(); } }} className="w-full rounded-sm bg-background px-1 py-0.5 text-sm outline-none ring-1 ring-border focus-visible:ring-brand" /> ); } function useFormatTimeAgo(): (dateStr: string) => string { const { t } = useT("chat"); return (dateStr: string) => { const date = new Date(dateStr); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMs / 3600000); const diffDays = Math.floor(diffMs / 86400000); if (diffMins < 1) return t(($) => $.session_history.time.just_now); if (diffMins < 60) return t(($) => $.session_history.time.minutes, { count: diffMins }); if (diffHours < 24) return t(($) => $.session_history.time.hours, { count: diffHours }); if (diffDays < 7) return t(($) => $.session_history.time.days, { count: diffDays }); return date.toLocaleDateString(); }; } // Three starter prompts shown on the empty state. Each is keyed into the // chat namespace so labels translate per locale; the icon stays raw since // emojis are locale-neutral. const STARTER_KEYS: ("list_open" | "summarize_today" | "plan_next")[] = [ "list_open", "summarize_today", "plan_next", ]; const STARTER_ICONS: Record<(typeof STARTER_KEYS)[number], string> = { list_open: "📋", summarize_today: "📝", plan_next: "💡", }; function EmptyState({ hasSessions, agentName, onPickPrompt, }: { hasSessions: boolean; agentName?: string; onPickPrompt: (text: string) => void; }) { const { t } = useT("chat"); // First-time experience: the user has never started a chat in this // workspace. Educate before suggesting actions — starter prompts // presume the user already knows what chat is for. if (!hasSessions) { return (

{t(($) => $.empty_state.first_time_title)}

{t(($) => $.empty_state.first_time_intro)}{" "} {t(($) => $.empty_state.first_time_pillars)} {t(($) => $.empty_state.first_time_pillars_suffix)}

{t(($) => $.empty_state.first_time_actions)}

); } // Returning user: starter prompts are the fastest path back to action. return (

{agentName ? t(($) => $.empty_state.returning_title_named, { name: agentName }) : t(($) => $.empty_state.returning_title_default)}

{t(($) => $.empty_state.returning_subtitle)}

{STARTER_KEYS.map((key) => { const text = t(($) => $.starter_prompts[key]); return ( ); })}
); }