Files
multica/packages/views/chat/components/chat-window.tsx
YYClaw 9eddcaff10 fix(chat): defer cancellation-time finalization until the task transcript is stable (#5246)
A quick Stop before the agent's first token no longer races a late reply. Started-but-empty cancellations defer the empty/non-empty judgment until the daemon acks its transcript flush (or a grace-period sweeper fires), then settle to a single outcome. Empty outcomes persist a durable, creator-authorized draft restore (fetched/consumed via a dedicated endpoint, reconnect-safe and at-most-once) instead of broadcasting the prompt over the workspace bus.

Closes #5219
2026-07-15 00:52:27 +08:00

1629 lines
65 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { 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 { 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,
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 } from "./use-chat-controller";
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<typeof useQueryClient>,
sessionId: string,
message: ChatMessage,
) {
qc.setQueryData<InfiniteData<ChatMessagesPage>>(
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<typeof useQueryClient>,
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<ChatMessage[]>(
chatKeys.messages(sessionId),
replace,
);
qc.setQueryData<InfiniteData<ChatMessagesPage> | 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 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 = [], isSuccess: sessionsLoaded } = 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);
// 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 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),
);
// 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;
// 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 } = 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<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
// 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),
});
return session.id;
} finally {
sessionPromiseRef.current = null;
}
})();
sessionPromiseRef.current = promise;
return promise;
},
[activeSessionId, activeAgent, 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<boolean> => {
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<ChatMessage[]>(
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, ~50200ms,
// which doesn't change the rendered "Ns" value).
qc.setQueryData<ChatPendingTask>(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), {});
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<ChatPendingTask>(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,
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);
// Reset session when switching agent
setActiveSession(null);
requestInputFocus();
},
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession, requestInputFocus],
);
const handleNewChat = useCallback(() => {
uiLogger.info("newChat", {
previousSessionId: activeSessionId,
previousPendingTask: pendingTaskId,
});
setActiveSession(null);
requestInputFocus();
}, [activeSessionId, pendingTaskId, 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 handleMinimize = useCallback(() => {
uiLogger.info("minimize (close)", {
activeSessionId,
pendingTaskId,
});
setOpen(false);
}, [activeSessionId, pendingTaskId, setOpen]);
const isExpanded = useChatStore((s) => s.isExpanded);
const windowRef = useRef<HTMLDivElement>(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 (
<motion.div
ref={windowRef}
className={containerClass}
style={containerStyle}
initial={{ opacity: 0, scale: 0.95, width: renderWidth, height: renderHeight }}
animate={{
opacity: isVisible ? 1 : 0,
scale: isVisible ? 1 : 0.95,
width: renderWidth,
height: renderHeight,
}}
transition={{
width: isDragging ? { duration: 0 } : { type: "spring", duration: 0.3, bounce: 0 },
height: isDragging ? { duration: 0 } : { type: "spring", duration: 0.3, bounce: 0 },
opacity: { duration: 0.15 },
scale: { type: "spring", duration: 0.2, bounce: 0 },
}}
>
<ChatResizeHandles onDragStart={startDrag} />
{/* Header — ⊕ new + session dropdown | window tools */}
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
<div className="flex items-center gap-1 min-w-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="rounded-full text-muted-foreground"
onClick={handleNewChat}
/>
}
>
<Plus />
</TooltipTrigger>
<TooltipContent side="top">{t(($) => $.window.new_chat_tooltip)}</TooltipContent>
</Tooltip>
<SessionDropdown
sessions={sessions}
// Use the full agent list (incl. archived) so historical
// sessions can still resolve their avatar.
agents={agents}
activeSessionId={activeSessionId}
onSelectSession={handleSelectSession}
/>
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={toggleExpand}
/>
}
>
{isExpanded || isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="top">
{isExpanded || isAtMax ? t(($) => $.window.restore_tooltip) : t(($) => $.window.expand_tooltip)}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
onClick={handleMinimize}
/>
}
>
<Minus />
</TooltipTrigger>
<TooltipContent side="top">{t(($) => $.window.minimize_tooltip)}</TooltipContent>
</Tooltip>
</div>
</div>
{/* Messages / skeleton / empty state */}
{showSkeleton ? (
<ChatMessageSkeleton />
) : hasMessages ? (
<ChatMessageList
key={activeSessionId}
messages={messages}
pendingTask={pendingTask}
availability={availability}
firstItemIndex={firstItemIndex}
hasOlderMessages={!!hasOlderMessages}
isFetchingOlderMessages={isFetchingOlderMessages}
onLoadOlderMessages={() => void fetchOlderMessages()}
/>
) : (
<EmptyState
hasSessions={sessions.length > 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 ? (
<NoAgentBanner />
) : isAgentArchived ? (
<ArchivedAgentBanner agentName={activeAgent?.name} />
) : (
<OfflineBanner agentName={activeAgent?.name} availability={availability} />
)}
{/* 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). */}
<ChatInput
onSend={handleSend}
restoreDraftRequest={restoreDraftRequest}
onRestoreDraftApplied={handleRestoreDraftApplied}
onUploadFile={handleUploadFile}
onStop={handleStop}
isRunning={!!pendingTaskId}
disabled={isSessionArchived || isAgentArchived}
noAgent={noAgent}
agentArchived={isAgentArchived}
agentName={activeAgent?.name}
leftAdornment={
<AgentDropdown
agents={availableAgents}
activeAgent={activeAgent}
userId={user?.id}
onSelect={handleSelectAgent}
/>
}
contextItems={contextItems}
focusRequest={focusRequest}
/>
</motion.div>
);
}
/**
* 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 <span className="text-xs text-muted-foreground">{t(($) => $.window.no_agents)}</span>;
}
return (
<PropertyPicker
open={open}
onOpenChange={setOpen}
width="w-64"
align="start"
side="top"
searchable
searchPlaceholder={t(($) => $.window.agent_filter_placeholder)}
onSearchChange={setFilter}
triggerRender={
<button
type="button"
className="flex items-center gap-1.5 rounded-md px-1.5 py-1 -ml-1 cursor-pointer outline-none transition-colors hover:bg-accent aria-expanded:bg-accent"
/>
}
trigger={
<>
<ActorAvatar
actorType="agent"
actorId={activeAgent.id}
size="md"
enableHoverCard
showStatusDot
/>
<span className="text-xs font-medium max-w-28 truncate">{activeAgent.name}</span>
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</>
}
>
{filteredMine.length === 0 && filteredOthers.length === 0 ? (
<PickerEmpty />
) : (
<>
{filteredMine.length > 0 && (
<PickerSection label={t(($) => $.window.my_agents)}>
{filteredMine.map((agent) => (
<AgentPickerItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={handlePick}
/>
))}
</PickerSection>
)}
{filteredOthers.length > 0 && (
<PickerSection label={t(($) => $.window.others)}>
{filteredOthers.map((agent) => (
<AgentPickerItem
key={agent.id}
agent={agent}
isCurrent={agent.id === activeAgent.id}
onSelect={handlePick}
/>
))}
</PickerSection>
)}
</>
)}
</PropertyPicker>
);
}
function AgentPickerItem({
agent,
isCurrent,
onSelect,
}: {
agent: Agent;
isCurrent: boolean;
onSelect: (agent: Agent) => void;
}) {
return (
<PickerItem
selected={isCurrent}
onClick={() => onSelect(agent)}
>
<ActorAvatar
actorType="agent"
actorId={agent.id}
size="md"
enableHoverCard
showStatusDot
/>
<span className="truncate flex-1">{agent.name}</span>
</PickerItem>
);
}
/**
* 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 [confirmingStopId, setConfirmingStopId] = useState<string | null>(null);
const [stoppingTaskId, setStoppingTaskId] = useState<string | null>(null);
const [completedFlashIds, setCompletedFlashIds] = useState<Set<string>>(() => new Set());
const previousInFlightRef = useRef<Set<string>>(new Set());
const completedFlashTimersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(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<string | null>(null);
const setArchived = useSetChatSessionArchived();
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;
// Archive (not hard-delete) is the reversible, one-click default here — the
// same safety model as ChatThreadList. The floating window offers NO
// hard-delete: unarchive / delete live only in the full Chat page's Archived
// view (reachable via the expand button), so a stale floating dropdown can't
// bypass the "archive first, delete only from Archived" semantics.
const handleArchive = (session: ChatSession) => {
if (activeSessionId === session.id) {
// Archiving the session in view: advance to the next chat (fall back to
// the previous, clear only when none remain) instead of stranding the
// composer on a now read-only session — mirrors the Chat tab and the
// Inbox list. Routing the non-null advance through onSelectSession keeps
// selectedAgentId in sync when the next chat belongs to another agent.
const idx = historySessions.findIndex((s) => s.id === session.id);
const next = historySessions[idx + 1] ?? historySessions[idx - 1] ?? null;
if (next) onSelectSession(next);
else setActiveSession(null);
}
setArchived.mutate({ sessionId: session.id, archived: true });
};
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<PendingChatTasksResponse>(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 isConfirmingStop = confirmingStopId === session.id && !!pendingTask;
const isConfirmingAction = 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 (
<div
key={session.id}
aria-current={isCurrent ? "true" : undefined}
tabIndex={0}
onClick={() => {
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 && <span className="absolute left-0 top-1.5 bottom-1.5 w-0.5 rounded-full bg-brand" />}
{agent ? (
<ActorAvatar
actorType="agent"
actorId={agent.id}
size="md"
enableHoverCard
showStatusDot
/>
) : (
<span className="size-6 shrink-0" />
)}
<div className="min-w-0 flex-1">
{isRenaming ? (
<SessionRenameInput
initialValue={session.title ?? ""}
onSubmit={(value) => handleSubmitRename(session.id, value)}
onCancel={() => setRenamingId(null)}
/>
) : isConfirmingStop ? (
<div className="truncate text-sm font-medium text-destructive">
{t(($) => $.session_history.stop_dialog.title)}
</div>
) : (
<div
className={cn("truncate text-sm", (showUnread || showCompleted) && !isRunning && "font-medium")}
style={{
maskImage: "linear-gradient(to right, black calc(100% - 18px), transparent)",
WebkitMaskImage: "linear-gradient(to right, black calc(100% - 18px), transparent)",
}}
>
{titleText}
</div>
)}
</div>
{!isRenaming && (
isConfirmingStop && pendingTask ? (
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onPointerDown={(e) => {
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)}
</button>
<button
type="button"
onPointerDown={(e) => {
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)}
</button>
</div>
) : (
<div className="flex shrink-0 items-center">
<div className="flex h-7 items-center justify-end gap-1.5 text-xs text-muted-foreground group-hover/history-row:hidden">
{isRunning && <Loader2 className="size-3 animate-spin" />}
{showCompleted && !isRunning && <Check className="size-3 text-emerald-500" />}
{showUnread && !isRunning && !showCompleted && (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
className="size-1.5 rounded-full bg-brand"
/>
)}
<span className={cn("truncate", (showUnread || showCompleted || isRunning) && "font-medium text-foreground")}>{trailingStatus}</span>
</div>
<div className="hidden h-7 items-center gap-0.5 group-hover/history-row:flex">
{isRunning && pendingTask && (
<button
type="button"
onPointerDown={(e) => {
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)}
>
<Square className="size-2.5 fill-current" />
{t(($) => $.session_history.stop_action)}
</button>
)}
{!isRunning && (
<>
<button
type="button"
onPointerDown={(e) => {
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)}
>
<Pencil className="size-3.5" />
</button>
<button
type="button"
onPointerDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
handleArchive(session);
}}
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(($) => $.list.archive)}
title={t(($) => $.list.archive)}
>
<Archive className="size-3.5" />
</button>
</>
)}
</div>
</div>
)
)}
</div>
);
};
return (
<>
<Popover open={isHistoryOpen} onOpenChange={setIsHistoryOpen}>
<div className="flex min-w-0 items-center gap-1">
<PopoverTrigger className="flex max-w-96 min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent data-[popup-open]:bg-accent data-open:bg-accent">
{triggerAgent && (
<ActorAvatar
actorType="agent"
actorId={triggerAgent.id}
size="md"
enableHoverCard
showStatusDot
/>
)}
<span className="min-w-0 truncate text-sm font-medium">{title}</span>
{currentSessionRunning && (
<Loader2
aria-label={t(($) => $.session_history.row_subtitle.working)}
className="size-3 shrink-0 animate-spin text-muted-foreground"
/>
)}
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
</PopoverTrigger>
{otherRunningCount > 0 ? (
<span
aria-label={t(($) => $.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"
>
<Loader2 className="size-3 animate-spin" />
{otherRunningCount > 1 && <span>{otherRunningCount}</span>}
</span>
) : otherUnreadCount > 0 ? (
<span
aria-label={t(($) => $.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"
>
<span className="size-1.5 rounded-full bg-brand" />
{otherUnreadCount > 1 && <span>{otherUnreadCount}</span>}
</span>
) : null}
</div>
<PopoverContent
align="start"
className="max-h-96 w-auto min-w-[max(16rem,var(--anchor-width,16rem))] max-w-96 gap-0 overflow-y-auto p-1"
onClick={(e) => e.stopPropagation()}
>
{historySessions.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t(($) => $.window.no_previous)}
</div>
) : (
<div role="group" aria-label={t(($) => $.window.history_group)}>
<div className="px-1.5 py-1 text-xs font-medium text-muted-foreground">
{t(($) => $.window.history_group)}
</div>
{historySessions.map(renderRow)}
</div>
)}
</PopoverContent>
</Popover>
</>
);
}
/**
* 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<HTMLInputElement>(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 (
<input
ref={inputRef}
type="text"
value={value}
maxLength={200}
aria-label={t(($) => $.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 (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-8">
<div className="text-center space-y-3">
<h3 className="text-base font-semibold">
{t(($) => $.empty_state.first_time_title)}
</h3>
<p className="text-sm text-muted-foreground">
{t(($) => $.empty_state.first_time_intro)}{" "}
<span className="font-medium text-foreground">
{t(($) => $.empty_state.first_time_pillars)}
</span>
{t(($) => $.empty_state.first_time_pillars_suffix)}
</p>
<p className="text-sm text-muted-foreground">
{t(($) => $.empty_state.first_time_actions)}
</p>
</div>
</div>
);
}
// Returning user: starter prompts are the fastest path back to action.
return (
<div className="flex flex-1 flex-col items-center justify-center gap-5 px-6 py-8">
<div className="text-center space-y-1">
<h3 className="text-base font-semibold">
{agentName
? t(($) => $.empty_state.returning_title_named, { name: agentName })
: t(($) => $.empty_state.returning_title_default)}
</h3>
<p className="text-sm text-muted-foreground">
{t(($) => $.empty_state.returning_subtitle)}
</p>
</div>
<div className="w-full max-w-xs space-y-2">
{STARTER_KEYS.map((key) => {
const text = t(($) => $.starter_prompts[key]);
return (
<button
key={key}
type="button"
onClick={() => 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"
>
<span className="mr-2">{STARTER_ICONS[key]}</span>
{text}
</button>
);
})}
</div>
</div>
);
}