onDragStart(e, "top")}
+ className="absolute top-0 left-4 right-0 h-1 z-10 cursor-row-resize"
+ />
+ {/* Top-left corner — expands both width and height */}
+
onDragStart(e, "corner")}
+ className="absolute top-0 left-0 size-4 z-20 cursor-nw-resize"
+ />
+ >
+ );
+}
diff --git a/packages/views/chat/components/chat-window.tsx b/packages/views/chat/components/chat-window.tsx
index be386862b0..058a24fd62 100644
--- a/packages/views/chat/components/chat-window.tsx
+++ b/packages/views/chat/components/chat-window.tsx
@@ -1,199 +1,1618 @@
"use client";
-import { useEffect, useState } from "react";
-import { ArrowLeft, Maximize2, X } from "lucide-react";
+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, Trash2, 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 {
- Tooltip,
- TooltipTrigger,
- TooltipContent,
-} from "@multica/ui/components/ui/tooltip";
-import { useChatStore } from "@multica/core/chat";
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@multica/ui/components/ui/popover";
+import { toast } from "sonner";
import { useWorkspaceId } from "@multica/core/hooks";
-import { useWorkspacePaths } from "@multica/core/paths";
-import type { Agent, ChatSession } from "@multica/core/types";
-import { useNavigation } from "../../navigation";
-import { useT } from "../../i18n";
-import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
-import { ChatInput } from "./chat-input";
-import { ChatThreadList } from "./chat-thread-list";
-import { ChatSessionHeader } from "./chat-session-header";
-import { EmptyState } from "./chat-empty-state";
-import { NewChatButton } from "./new-chat-button";
+import { useAuthStore } from "@multica/core/auth";
+import { agentListOptions, memberListOptions } from "@multica/core/workspace/queries";
+import { canAssignAgent } from "@multica/views/issues/components";
+import { api } from "@multica/core/api";
+import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
+import { useFileUpload } from "@multica/core/hooks/use-file-upload";
+import { ActorAvatar } from "../../common/actor-avatar";
+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 { useChatController } from "./use-chat-controller";
+import {
+ chatSessionsOptions,
+ chatMessagesPageOptions,
+ pendingChatTaskOptions,
+ pendingChatTasksOptions,
+ chatKeys,
+ isTaskMessageTaskId,
+} from "@multica/core/chat/queries";
+import {
+ useCreateChatSession,
+ useDeleteChatSession,
+ useMarkChatSessionRead,
+ useUpdateChatSession,
+} from "@multica/core/chat/mutations";
+import { useChatStore } from "@multica/core/chat";
+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 { 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 removeChatMessageFromPageCache(
+ qc: ReturnType,
+ sessionId: string,
+ messageId: string,
+) {
+ qc.setQueryData | undefined>(
+ chatKeys.messagesPage(sessionId),
+ (old) => {
+ if (!old) return old;
+ return {
+ ...old,
+ pages: old.pages.map((page) => ({
+ ...page,
+ messages: page.messages.filter((m) => m.id !== messageId),
+ })),
+ };
+ },
+ );
+}
+
+function removeChatMessageFromCaches(
+ qc: ReturnType,
+ sessionId: string,
+ messageId: string,
+) {
+ qc.setQueryData(
+ chatKeys.messages(sessionId),
+ (old) => old?.filter((m) => m.id !== messageId) ?? old,
+ );
+ removeChatMessageFromPageCache(qc, sessionId, messageId);
+}
+
+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,
+ })),
+ };
+ },
+ );
+}
-/**
- * Floating chat overlay — the "summon anywhere" surface. Shares all
- * conversation logic with the Chat tab ({@link ChatPage}) via
- * {@link useChatController}, so `activeSessionId` (the Zustand source of truth)
- * stays in lockstep across both: opening or starting a thread here is
- * reflected in the tab and vice-versa. Mounting / route gating lives in
- * {@link FloatingChat}; this component assumes it is allowed to render.
- *
- * Unlike the tab, this overlay passes `contextItems`, so `@` in the composer
- * surfaces the issue/project of the page you summoned it from (the "current
- * context" affordance) — the whole point of a summon-anywhere entry point.
- */
export function ChatWindow() {
const { t } = useT("chat");
const wsId = useWorkspaceId();
- const wsPaths = useWorkspacePaths();
- const { push } = useNavigation();
const isOpen = useChatStore((s) => s.isOpen);
+ const activeSessionId = useChatStore((s) => s.activeSessionId);
+ const selectedAgentId = useChatStore((s) => s.selectedAgentId);
const setOpen = useChatStore((s) => s.setOpen);
+ const setActiveSession = useChatStore((s) => s.setActiveSession);
+ const setSelectedAgentId = useChatStore((s) => s.setSelectedAgentId);
+ 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 = [] } = useQuery(chatSessionsOptions(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);
+ const [restoreDraftRequest, setRestoreDraftRequest] = useState<{
+ id: string;
+ content: string;
+ attachments?: Attachment[];
+ sessionId?: string;
+ } | null>(null);
+ const handleRestoreDraftConsumed = useCallback(() => {
+ setRestoreDraftRequest(null);
+ }, []);
+
+ // 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 qc = useQueryClient();
+ const createSession = useCreateChatSession();
+ const markRead = useMarkChatSessionRead();
+
+ 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),
+ );
+
+ // Resolve selected agent: stored preference → first available
+ const activeAgent =
+ availableAgents.find((a) => a.id === selectedAgentId) ??
+ availableAgents[0] ??
+ null;
+
+ // 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
+ }, []);
+
+ // Open intent is fully driven by `activeSessionId` in storage — no mount
+ // restore, no self-heal. Adding either reintroduces a "two signals
+ // describing one fact" race (the previous self-heal mis-cleared the
+ // freshly-created session because allSessions was still stale during the
+ // post-create invalidate-refetch window).
+
+ // 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 + 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.
+ const currentHasUnread =
+ sessions.find((s) => s.id === activeSessionId)?.has_unread ?? false;
+ useEffect(() => {
+ if (!isOpen || !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, activeSessionId, currentHasUnread]);
+
+ const { uploadWithToast } = useFileUpload(api);
+
+ // 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 => {
+ if (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),
+ });
+ return session.id;
+ } finally {
+ sessionPromiseRef.current = null;
+ }
+ })();
+ sessionPromiseRef.current = promise;
+ return promise;
+ },
+ [activeSessionId, activeAgent, createSession],
+ );
+
+ 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) {
+ setRestoreDraftRequest({
+ 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],
+ );
+
+ 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;
+ }
+
+ 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 steal
+ // focus if the user is STILL on the compose target they sent from — if
+ // they navigated away mid-send, this is fire-and-forget: the reply
+ // surfaces via the unread dot on the sent session, we don't yank the
+ // view back. Compare the live store against the closure-captured target.
+ // For a brand-new chat (activeSessionId === null) the target is keyed by
+ // the selected agent, so switching agents to start a different new chat
+ // must also count as "navigated away" even though both sides are null.
+ const live = useChatStore.getState();
+ const stillOnSourceSession =
+ live.activeSessionId === activeSessionId &&
+ (activeSessionId !== null || live.selectedAgentId === selectedAgentId);
+ 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), {});
+ setRestoreDraftRequest({
+ 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 until they
+ // return rather than dumping content into another session.
+ 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,
+ selectedAgentId,
+ activeAgent,
+ ensureSession,
+ cancelChatTask,
+ qc,
+ setActiveSession,
+ 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);
+ // Reset session when switching agent
+ setActiveSession(null);
+ },
+ [activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
+ );
+
+ const handleNewChat = useCallback(() => {
+ uiLogger.info("newChat", {
+ previousSessionId: activeSessionId,
+ previousPendingTask: pendingTaskId,
+ });
+ setActiveSession(null);
+ }, [activeSessionId, pendingTaskId, setActiveSession]);
+
+ 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 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 rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
+ const containerStyle: React.CSSProperties = {
+ transformOrigin: "bottom right",
+ pointerEvents: isOpen ? "auto" : "none",
+ };
- const c = useChatController({ isActive: isOpen });
const contextItems = useChatContextItems(wsId);
- // "Composing a brand-new chat": the user hit ⊕ but hasn't sent yet, so no
- // session exists. Mirrors ChatPage; resets once a real session takes over.
- const [composingNew, setComposingNew] = useState(false);
- useEffect(() => {
- if (c.activeSessionId) setComposingNew(false);
- }, [c.activeSessionId]);
+ 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)}
+
+
+
- if (!isOpen) return null;
+ {/* Messages / skeleton / empty state */}
+ {showSkeleton ? (
+
+ ) : hasMessages ? (
+ void fetchOlderMessages()}
+ />
+ ) : (
+ 0}
+ agentName={activeAgent?.name}
+ onPickPrompt={(text) => handleSend(text)}
+ />
+ )}
- const handleSelect = (session: ChatSession) => {
- c.handleSelectSession(session);
- setComposingNew(false);
- };
+ {/* 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 ? (
+
+ ) : (
+
+ )}
- const startNewChat = (agent: Agent | null) => {
- if (agent) c.handleStartNewChat(agent);
- else c.handleNewChat();
- setComposingNew(true);
- };
+ {/* Input — disabled for legacy archived sessions; locked out entirely
+ * when there's no agent (the EmptyState above carries the CTA). */}
+
+ }
+ contextItems={contextItems}
+ />
+
+ );
+}
- const backToList = () => {
- c.setActiveSession(null);
- setComposingNew(false);
- };
+/**
+ * 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 openFullPage = () => {
- push(c.activeSessionId ? `${wsPaths.chat()}?session=${c.activeSessionId}` : wsPaths.chat());
+ 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);
};
- const hasTarget = !!c.activeSessionId || composingNew;
-
- const headerButtons = (
-
-
-
-
-
-
- }
- />
- {t(($) => $.window.open_full_tooltip)}
-
-
- setOpen(false)}>
-
-
- }
- />
- {t(($) => $.window.minimize_tooltip)}
-
-
- );
-
- const conversation = (
-
-
-
-
-
-
- {c.currentSession && (
-
- )}
-
-
- {c.showSkeleton ? (
-
- ) : c.hasMessages ? (
-
void c.fetchOlderMessages()}
- />
- ) : (
-
- )}
-
- {c.noAgent ? (
-
- ) : (
-
- )}
-
-
-
- );
+ if (!activeAgent) {
+ return {t(($) => $.window.no_agents)} ;
+ }
return (
-
- {!hasTarget && (
-
-
{t(($) => $.page.title)}
- {headerButtons}
-
- )}
- {hasTarget ? (
+
$.window.agent_filter_placeholder)}
+ onSearchChange={setFilter}
+ triggerRender={
+
+ }
+ trigger={
<>
- {/* When a thread is open the ⊕ / expand / close cluster rides the
- conversation header via the shared ChatSessionHeader row above;
- expose the window controls in a compact top strip. */}
-
- {headerButtons}
-
- {conversation}
- >
- ) : (
-
-
-
+ {activeAgent.name}
+
+ >
+ }
+ >
+ {filteredMine.length === 0 && filteredOthers.length === 0 ? (
+
+ ) : (
+ <>
+ {filteredMine.length > 0 && (
+ $.window.my_agents)}>
+ {filteredMine.map((agent) => (
+
+ ))}
+
+ )}
+ {filteredOthers.length > 0 && (
+ $.window.others)}>
+ {filteredOthers.map((agent) => (
+
+ ))}
+
+ )}
+ >
)}
+
+ );
+}
+
+function AgentPickerItem({
+ agent,
+ isCurrent,
+ onSelect,
+}: {
+ agent: Agent;
+ isCurrent: boolean;
+ onSelect: (agent: Agent) => void;
+}) {
+ return (
+
onSelect(agent)}
+ >
+
+ {agent.name}
+
+ );
+}
+
+/**
+ * Session dropdown: a flat "Chat history" list of all non-archived
+ * sessions. Selecting a session from a different agent implicitly
+ * switches the agent too
+ * (sessions are bound 1:1 to an agent). "New chat" lives in the header's
+ * ⊕ button, not inside this dropdown.
+ */
+function SessionDropdown({
+ sessions,
+ agents,
+ activeSessionId,
+ onSelectSession,
+}: {
+ sessions: ChatSession[];
+ agents: Agent[];
+ activeSessionId: string | null;
+ onSelectSession: (session: ChatSession) => void;
+}) {
+ const { t } = useT("chat");
+ const wsId = useWorkspaceId();
+ const agentById = useMemo(() => new Map(agents.map((a) => [a.id, a])), [agents]);
+ const activeSession = sessions.find((s) => s.id === activeSessionId);
+ const title = activeSession?.title?.trim() || t(($) => $.window.untitled);
+ const triggerAgent = activeSession ? agentById.get(activeSession.agent_id) ?? null : null;
+
+ // The old soft-archive feature was removed. Pre-existing rows with
+ // status='archived' are legacy dead data and are excluded from history.
+ const historySessions = useMemo(
+ () => sessions.filter((s) => s.status !== "archived"),
+ [sessions],
+ );
+
+ const [isHistoryOpen, setIsHistoryOpen] = useState(false);
+ const [confirmingDeleteId, setConfirmingDeleteId] = useState
(null);
+ const [confirmingStopId, setConfirmingStopId] = useState(null);
+ const [stoppingTaskId, setStoppingTaskId] = useState(null);
+ const [completedFlashIds, setCompletedFlashIds] = useState>(() => new Set());
+ const previousInFlightRef = useRef>(new Set());
+ const completedFlashTimersRef = useRef>>(new Map());
+ // Inline rename: only one row can be in edit mode at a time. We track the
+ // session id (not the full session) so a stale closure can't overwrite a
+ // newer rename pulled in via WS.
+ const [renamingId, setRenamingId] = useState(null);
+ const deleteSession = useDeleteChatSession();
+ const updateSession = useUpdateChatSession();
+ const setActiveSession = useChatStore((s) => s.setActiveSession);
+ const queryClient = useQueryClient();
+ const formatTimeAgo = useFormatTimeAgo();
+
+ // Aggregate "which sessions have an in-flight task right now". Reuses
+ // the same workspace-scoped query the FAB consumes, so toggling the chat
+ // window doesn't fire a second request — TanStack dedupes by key.
+ const { data: pending } = useQuery(pendingChatTasksOptions(wsId));
+ const pendingTaskBySessionId = useMemo(
+ () => new Map((pending?.tasks ?? []).map((task) => [task.chat_session_id, task])),
+ [pending],
+ );
+ const inFlightSessionIds = useMemo(
+ () => new Set(pendingTaskBySessionId.keys()),
+ [pendingTaskBySessionId],
+ );
+
+ useEffect(() => {
+ const previous = previousInFlightRef.current;
+ const unreadSessionIds = new Set(sessions.filter((s) => s.has_unread).map((s) => s.id));
+
+ for (const sessionId of previous) {
+ if (inFlightSessionIds.has(sessionId) || !unreadSessionIds.has(sessionId)) continue;
+
+ setCompletedFlashIds((current) => {
+ if (current.has(sessionId)) return current;
+ return new Set(current).add(sessionId);
+ });
+
+ const existingTimer = completedFlashTimersRef.current.get(sessionId);
+ if (existingTimer) clearTimeout(existingTimer);
+
+ const timer = setTimeout(() => {
+ setCompletedFlashIds((current) => {
+ if (!current.has(sessionId)) return current;
+ const next = new Set(current);
+ next.delete(sessionId);
+ return next;
+ });
+ completedFlashTimersRef.current.delete(sessionId);
+ }, 1600);
+ completedFlashTimersRef.current.set(sessionId, timer);
+ }
+
+ previousInFlightRef.current = inFlightSessionIds;
+ }, [inFlightSessionIds, sessions]);
+
+ useEffect(() => {
+ const timers = completedFlashTimersRef.current;
+ return () => {
+ for (const timer of timers.values()) clearTimeout(timer);
+ timers.clear();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!confirmingStopId || pendingTaskBySessionId.has(confirmingStopId)) return;
+ setConfirmingStopId(null);
+ }, [confirmingStopId, pendingTaskBySessionId]);
+
+ // Header state split:
+ // - inside the trigger: the current chat's own live state
+ // - beside the trigger: aggregate activity from other chats
+ const currentSessionRunning = activeSessionId ? inFlightSessionIds.has(activeSessionId) : false;
+ const otherRunningCount = sessions.filter(
+ (s) => s.id !== activeSessionId && inFlightSessionIds.has(s.id),
+ ).length;
+ const otherUnreadCount = sessions.filter(
+ (s) => s.id !== activeSessionId && s.has_unread,
+ ).length;
+
+ const handleConfirmDelete = (session: ChatSession) => {
+ const sessionId = session.id;
+ const isDeletingCurrent = activeSessionId === sessionId;
+ // Eager local clear when the user is deleting the session they're
+ // currently looking at — otherwise messages / pendingTask queries
+ // keep rendering the now-deleted session until chat:session_deleted
+ // arrives over WS (~50–200ms gap).
+ if (isDeletingCurrent) {
+ setActiveSession(null);
+ }
+ deleteSession.mutate(sessionId, {
+ onSettled: () => setConfirmingDeleteId(null),
+ });
+ };
+
+ const handleSubmitRename = (sessionId: string, raw: string) => {
+ const trimmed = raw.trim();
+ const current = sessions.find((s) => s.id === sessionId);
+ setRenamingId(null);
+ // No-op submits (unchanged or blank) skip the network round-trip — the
+ // server would reject a blank title anyway, and an unchanged title would
+ // just bump updated_at for no user-visible reason.
+ if (!trimmed || trimmed === current?.title) return;
+ updateSession.mutate({ sessionId, title: trimmed });
+ };
+
+ const handleSelectSession = (session: ChatSession) => {
+ onSelectSession(session);
+ setIsHistoryOpen(false);
+ };
+
+ const handleConfirmStop = (session: ChatSession, task: PendingChatTasksResponse["tasks"][number]) => {
+ setStoppingTaskId(task.task_id);
+ previousInFlightRef.current = new Set(
+ [...previousInFlightRef.current].filter((sessionId) => sessionId !== session.id),
+ );
+
+ // Same optimistic behavior as the active chat Stop button: remove the
+ // running affordance immediately, then let task:cancelled / refetches
+ // converge every open surface on the server truth.
+ queryClient.setQueryData(chatKeys.pendingTasks(wsId), (current) => {
+ if (!current) return current;
+ return {
+ ...current,
+ tasks: current.tasks.filter((item) => item.task_id !== task.task_id),
+ };
+ });
+ queryClient.setQueryData(chatKeys.pendingTask(session.id), {});
+ queryClient.invalidateQueries({ queryKey: chatKeys.messages(session.id) });
+ queryClient.invalidateQueries({ queryKey: chatKeys.messagesPage(session.id) });
+
+ api.cancelTaskById(task.task_id).then(
+ (result) => {
+ const restored = result.cancelled_chat_message;
+ if (restored?.restore_to_input) {
+ removeChatMessageFromCaches(queryClient, restored.chat_session_id, restored.message_id);
+ }
+ apiLogger.info("cancelTask.success (history row)", { taskId: task.task_id, sessionId: session.id });
+ },
+ (err) =>
+ apiLogger.warn("cancelTask.error (history row; task may have already finished)", {
+ taskId: task.task_id,
+ sessionId: session.id,
+ err,
+ }),
+ ).finally(() => {
+ queryClient.invalidateQueries({ queryKey: chatKeys.pendingTasks(wsId) });
+ queryClient.invalidateQueries({ queryKey: chatKeys.pendingTask(session.id) });
+ setStoppingTaskId(null);
+ setConfirmingStopId(null);
+ });
+ };
+
+ const renderRow = (session: ChatSession) => {
+ const isCurrent = session.id === activeSessionId;
+ const agent = agentById.get(session.agent_id) ?? null;
+ const pendingTask = pendingTaskBySessionId.get(session.id);
+ const isRunning = !!pendingTask;
+ const showCompleted = completedFlashIds.has(session.id) && !isCurrent;
+ const showUnread = session.has_unread && !isCurrent;
+ const isRenaming = renamingId === session.id;
+ const isConfirmingDelete = confirmingDeleteId === session.id;
+ const isConfirmingStop = confirmingStopId === session.id && !!pendingTask;
+ const isConfirmingAction = isConfirmingDelete || isConfirmingStop;
+ const titleText = session.title?.trim() || t(($) => $.window.untitled);
+ const trailingStatus = isRunning
+ ? t(($) => $.session_history.row_subtitle.working)
+ : showCompleted
+ ? t(($) => $.session_history.row_subtitle.completed)
+ : showUnread
+ ? t(($) => $.session_history.row_subtitle.new_reply)
+ : formatTimeAgo(session.updated_at);
+
+ return (
+ {
+ if (isRenaming || isConfirmingAction) return;
+ handleSelectSession(session);
+ }}
+ onKeyDown={(e) => {
+ if (isRenaming || isConfirmingAction) return;
+ if (e.key !== "Enter" && e.key !== " ") return;
+ e.preventDefault();
+ handleSelectSession(session);
+ }}
+ className={cn(
+ "group/history-row relative flex min-h-11 min-w-0 cursor-default items-center gap-2 overflow-hidden rounded-md py-1.5 pl-2 pr-2 outline-none transition-colors hover:bg-accent/60 focus-visible:bg-accent/60 focus-visible:ring-1 focus-visible:ring-ring",
+ isCurrent && "bg-accent/70",
+ isConfirmingAction && "bg-destructive/5 hover:bg-destructive/5",
+ )}
+ >
+ {isCurrent &&
}
+ {agent ? (
+
+ ) : (
+
+ )}
+
+ {isRenaming ? (
+
handleSubmitRename(session.id, value)}
+ onCancel={() => setRenamingId(null)}
+ />
+ ) : isConfirmingDelete ? (
+
+ {t(($) => $.session_history.delete_dialog.title)}
+
+ ) : isConfirmingStop ? (
+
+ {t(($) => $.session_history.stop_dialog.title)}
+
+ ) : (
+
+ {titleText}
+
+ )}
+
+ {!isRenaming && (
+ isConfirmingDelete ? (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setConfirmingDeleteId(null);
+ }}
+ disabled={deleteSession.isPending}
+ className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
+ >
+ {t(($) => $.session_history.delete_dialog.cancel)}
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ handleConfirmDelete(session);
+ }}
+ disabled={deleteSession.isPending}
+ className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
+ >
+ {deleteSession.isPending
+ ? t(($) => $.session_history.delete_dialog.confirming)
+ : t(($) => $.session_history.delete_dialog.confirm)}
+
+
+ ) : isConfirmingStop && pendingTask ? (
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setConfirmingStopId(null);
+ }}
+ disabled={stoppingTaskId === pendingTask.task_id}
+ className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50"
+ >
+ {t(($) => $.session_history.stop_dialog.cancel)}
+
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ handleConfirmStop(session, pendingTask);
+ }}
+ disabled={stoppingTaskId === pendingTask.task_id}
+ className="inline-flex h-7 items-center rounded px-2 text-[11px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50"
+ >
+ {stoppingTaskId === pendingTask.task_id
+ ? t(($) => $.session_history.stop_dialog.confirming)
+ : t(($) => $.session_history.stop_dialog.confirm)}
+
+
+ ) : (
+
+
+ {isRunning && }
+ {showCompleted && !isRunning && }
+ {showUnread && !isRunning && !showCompleted && (
+ $.window.unread)}
+ title={t(($) => $.window.unread)}
+ className="size-1.5 rounded-full bg-brand"
+ />
+ )}
+ {trailingStatus}
+
+
+ {isRunning && pendingTask && (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setConfirmingStopId(session.id);
+ }}
+ className="inline-flex h-7 items-center gap-1 rounded px-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:outline-none"
+ aria-label={t(($) => $.session_history.row_stop_aria)}
+ title={t(($) => $.session_history.row_stop_aria)}
+ >
+
+ {t(($) => $.session_history.stop_action)}
+
+ )}
+ {!isRunning && (
+ <>
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setRenamingId(session.id);
+ }}
+ className="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:outline-none"
+ aria-label={t(($) => $.session_history.row_rename_aria)}
+ title={t(($) => $.session_history.row_rename_aria)}
+ >
+
+
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onClick={(e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setConfirmingDeleteId(session.id);
+ }}
+ className="inline-flex size-7 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:bg-destructive/10 focus-visible:text-destructive focus-visible:outline-none"
+ aria-label={t(($) => $.session_history.row_delete_aria)}
+ title={t(($) => $.session_history.row_delete_aria)}
+ >
+
+
+ >
+ )}
+
+
+ )
+ )}
+
+ );
+ };
+
+ 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 (
+ onPickPrompt(text)}
+ className="w-full rounded-lg border border-border bg-card px-3 py-2 text-left text-sm text-foreground transition-colors hover:bg-accent hover:border-brand/40"
+ >
+ {STARTER_ICONS[key]}
+ {text}
+
+ );
+ })}
+
);
}
diff --git a/packages/views/chat/components/use-chat-resize.ts b/packages/views/chat/components/use-chat-resize.ts
new file mode 100644
index 0000000000..01a53ddeb7
--- /dev/null
+++ b/packages/views/chat/components/use-chat-resize.ts
@@ -0,0 +1,140 @@
+"use client";
+
+import React, { useRef, useCallback, useState, useEffect } from "react";
+import { CHAT_MIN_W, CHAT_MIN_H, useChatStore } from "@multica/core/chat";
+
+type DragDir = "left" | "top" | "corner";
+
+const MAX_RATIO = 0.9;
+const FALLBACK_MAX_W = 800;
+const FALLBACK_MAX_H = 700;
+
+function clamp(v: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, v));
+}
+
+export function useChatResize(
+ windowRef: React.RefObject,
+) {
+ const chatWidth = useChatStore((s) => s.chatWidth);
+ const chatHeight = useChatStore((s) => s.chatHeight);
+ const isExpanded = useChatStore((s) => s.isExpanded);
+ const setChatSize = useChatStore((s) => s.setChatSize);
+ const setExpanded = useChatStore((s) => s.setExpanded);
+
+ // ── Container bounds via ResizeObserver ────────────────────────────────
+ const boundsRef = useRef({ maxW: FALLBACK_MAX_W, maxH: FALLBACK_MAX_H });
+ const [boundsReady, setBoundsReady] = useState(false);
+ const [isDragging, setIsDragging] = useState(false);
+ const [, setRevision] = useState(0);
+
+ useEffect(() => {
+ const el = windowRef.current;
+ const parent = el?.parentElement;
+ if (!parent) return;
+
+ const update = () => {
+ const maxW = Math.floor(parent.clientWidth * MAX_RATIO);
+ const maxH = Math.floor(parent.clientHeight * MAX_RATIO);
+ setBoundsReady(true); // idempotent once true
+ // Only trigger a re-render if the bounds actually changed. Without this
+ // guard, any spurious ResizeObserver notification (including sub-pixel
+ // layout jitter during mount) schedules a setState that feeds back into
+ // the observer, producing "Maximum update depth exceeded".
+ const prev = boundsRef.current;
+ if (prev.maxW === maxW && prev.maxH === maxH) return;
+ boundsRef.current = { maxW, maxH };
+ setRevision((r) => r + 1);
+ };
+
+ // Measure immediately (parent is already in DOM at this point)
+ update();
+
+ const ro = new ResizeObserver(update);
+ ro.observe(parent);
+ return () => ro.disconnect();
+ }, [windowRef]);
+
+ // ── Derive rendered size ──────────────────────────────────────────────
+ const { maxW, maxH } = boundsRef.current;
+
+ const renderWidth = isExpanded ? maxW : clamp(chatWidth, CHAT_MIN_W, maxW);
+ const renderHeight = isExpanded ? maxH : clamp(chatHeight, CHAT_MIN_H, maxH);
+
+ // ── Expand / Restore ──────────────────────────────────────────────────
+ const isAtMax = renderWidth >= maxW && renderHeight >= maxH;
+
+ const toggleExpand = useCallback(() => {
+ if (isExpanded || isAtMax) {
+ setChatSize(CHAT_MIN_W, CHAT_MIN_H);
+ } else {
+ setExpanded(true);
+ }
+ }, [isExpanded, isAtMax, setChatSize, setExpanded]);
+
+ // ── Drag ──────────────────────────────────────────────────────────────
+ const dragRef = useRef<{
+ startX: number;
+ startY: number;
+ startW: number;
+ startH: number;
+ dir: DragDir;
+ } | null>(null);
+
+ const startDrag = useCallback(
+ (e: React.PointerEvent, dir: DragDir) => {
+ e.preventDefault();
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
+
+ dragRef.current = {
+ startX: e.clientX,
+ startY: e.clientY,
+ startW: renderWidth,
+ startH: renderHeight,
+ dir,
+ };
+ setIsDragging(true);
+
+ const onPointerMove = (ev: PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+
+ const { maxW: mw, maxH: mh } = boundsRef.current;
+
+ const rawW =
+ dir === "left" || dir === "corner"
+ ? d.startW - (ev.clientX - d.startX)
+ : d.startW;
+ const rawH =
+ dir === "top" || dir === "corner"
+ ? d.startH - (ev.clientY - d.startY)
+ : d.startH;
+
+ setChatSize(clamp(rawW, CHAT_MIN_W, mw), clamp(rawH, CHAT_MIN_H, mh));
+ };
+
+ const onPointerUp = () => {
+ dragRef.current = null;
+ setIsDragging(false);
+ document.removeEventListener("pointermove", onPointerMove);
+ document.removeEventListener("pointerup", onPointerUp);
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ };
+
+ document.addEventListener("pointermove", onPointerMove);
+ document.addEventListener("pointerup", onPointerUp);
+
+ const cursorMap: Record = {
+ left: "col-resize",
+ top: "row-resize",
+ corner: "nw-resize",
+ };
+ document.body.style.cursor = cursorMap[dir];
+ document.body.style.userSelect = "none";
+ },
+ [renderWidth, renderHeight, setChatSize],
+ );
+
+ return { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag };
+}
diff --git a/packages/views/locales/en/chat.json b/packages/views/locales/en/chat.json
index a6678797ac..c10f08c0e9 100644
--- a/packages/views/locales/en/chat.json
+++ b/packages/views/locales/en/chat.json
@@ -72,7 +72,6 @@
}
},
"window": {
- "open_full_tooltip": "Open full page",
"new_chat_tooltip": "New chat",
"restore_tooltip": "Restore",
"expand_tooltip": "Expand",
@@ -97,8 +96,14 @@
"first_time_actions": "Ask for a summary, plan your day, or hand off a quick task.",
"returning_title_named": "Hi, I'm {{name}}",
"returning_title_default": "Welcome to Multica",
+ "returning_subtitle": "Try asking",
"chat_with_named": "Chat with {{name}}"
},
+ "starter_prompts": {
+ "list_open": "List my open tasks by priority",
+ "summarize_today": "Summarize what I did today",
+ "plan_next": "Plan what to work on next"
+ },
"no_agent_banner": "You need an agent to start chatting.",
"offline_banner": {
"fallback_name": "the agent",
diff --git a/packages/views/locales/ja/chat.json b/packages/views/locales/ja/chat.json
index 0fb6471063..ef63d945b7 100644
--- a/packages/views/locales/ja/chat.json
+++ b/packages/views/locales/ja/chat.json
@@ -69,7 +69,6 @@
}
},
"window": {
- "open_full_tooltip": "全画面で開く",
"new_chat_tooltip": "新規チャット",
"restore_tooltip": "復元",
"expand_tooltip": "展開",
@@ -94,8 +93,14 @@
"first_time_actions": "要約を頼んだり、今日の予定を立てたり、簡単なタスクを任せたりしてみましょう。",
"returning_title_named": "こんにちは、{{name}} です",
"returning_title_default": "Multica へようこそ",
+ "returning_subtitle": "こう聞いてみましょう",
"chat_with_named": "{{name}} とチャット"
},
+ "starter_prompts": {
+ "list_open": "オープンなタスクを優先度順にまとめて",
+ "summarize_today": "今日やったことを要約して",
+ "plan_next": "次に取り組むことを計画して"
+ },
"no_agent_banner": "チャットを始めるにはエージェントが必要です。",
"offline_banner": {
"fallback_name": "エージェント",
diff --git a/packages/views/locales/ko/chat.json b/packages/views/locales/ko/chat.json
index c46d3d90ba..975786c552 100644
--- a/packages/views/locales/ko/chat.json
+++ b/packages/views/locales/ko/chat.json
@@ -69,7 +69,6 @@
}
},
"window": {
- "open_full_tooltip": "전체 페이지 열기",
"new_chat_tooltip": "새 채팅",
"restore_tooltip": "복원",
"expand_tooltip": "펼치기",
@@ -94,8 +93,14 @@
"first_time_actions": "요약을 요청하거나, 오늘 할 일을 계획하거나, 간단한 작업을 맡겨보세요.",
"returning_title_named": "안녕하세요, 저는 {{name}}입니다",
"returning_title_default": "Multica에 오신 것을 환영합니다",
+ "returning_subtitle": "이렇게 요청해 보세요",
"chat_with_named": "{{name}}와 채팅"
},
+ "starter_prompts": {
+ "list_open": "열린 작업을 우선순위별로 정리해 줘",
+ "summarize_today": "오늘 내가 한 일을 요약해 줘",
+ "plan_next": "다음에 할 일을 계획해 줘"
+ },
"no_agent_banner": "채팅을 시작하려면 에이전트가 필요합니다.",
"offline_banner": {
"fallback_name": "에이전트",
diff --git a/packages/views/locales/zh-Hans/chat.json b/packages/views/locales/zh-Hans/chat.json
index fdea80507b..ddf2f7fc98 100644
--- a/packages/views/locales/zh-Hans/chat.json
+++ b/packages/views/locales/zh-Hans/chat.json
@@ -69,7 +69,6 @@
}
},
"window": {
- "open_full_tooltip": "打开完整页面",
"new_chat_tooltip": "新对话",
"restore_tooltip": "还原",
"expand_tooltip": "展开",
@@ -94,8 +93,14 @@
"first_time_actions": "让它做一份总结、规划你的一天,或交给它一个小任务。",
"returning_title_named": "你好,我是 {{name}}",
"returning_title_default": "欢迎使用 Multica",
+ "returning_subtitle": "试试问",
"chat_with_named": "与 {{name}} 对话"
},
+ "starter_prompts": {
+ "list_open": "按优先级列出我未完成的任务",
+ "summarize_today": "总结一下我今天做了什么",
+ "plan_next": "规划接下来该做什么"
+ },
"no_agent_banner": "需要先有一个智能体才能开始对话。",
"offline_banner": {
"fallback_name": "智能体",