Files
cline/apps/examples/desktop-app/webview/hooks/use-chat-session.ts
T
John Choi bcfa7c7e4d fix(desktop): keep Stop available for running child agents (#13678)
* fix(desktop): keep Stop available for running child agents

* fix(desktop): reconcile aborted tool activity

* fix(desktop): guard abort and agent polling races

* fix(desktop): preserve authoritative abort status

* fix(desktop): track queue-verified completion

* test(desktop): trim duplicate abort coverage

* fix(desktop): settle delayed queue verification
2026-08-31 12:09:01 -07:00

3209 lines
98 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
serializeAttachments,
toChatMessageImages,
} from "@/hooks/chat-session/attachments";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import {
buildToolPayloadString,
extractAssistantTurnDataFromRpcMessages,
inferHydratedChatStatus,
makeId,
mapSessionRecordStatus,
normalizeRuntimeConfig,
resolveCredentialError,
} from "@/hooks/chat-session/helpers";
import type {
AgentChunkEvent,
AskQuestionRequestItem,
ChatApiResult,
ChatSessionCommandResponse,
ChatSessionHookEvent,
ChatTransportState,
ChatUsageEvent,
CoreLogChunk,
ProcessContext,
PromptInQueue,
ReasoningDeltaEvent,
ToolApprovalRequestItem,
ToolCallEndEvent,
ToolCallStartEvent,
ToolCallUpdateEvent,
} from "@/hooks/chat-session/types";
import {
type ChatMessage,
ChatMessageImageSchema,
ChatMessageMediaSchema,
type ChatSessionConfig,
ChatSessionConfigSchema,
type ChatSessionStatus,
} from "@/lib/chat-schema";
import { appendCappedCommandOutput } from "@/lib/command-output";
import { desktopClient } from "@/lib/desktop-client";
import {
buildSessionDiffState,
EMPTY_DIFF_SUMMARY,
type SessionDiffSummary,
type SessionFileDiff,
type SessionHookEvent,
} from "@/lib/session-diff";
import type {
SessionHistoryItem,
SessionHistoryStatus,
} from "@/lib/session-history";
import {
normalizeWorkspacePath,
readWorkspaceSelectionFromWindow,
registerHostHomeDirectory,
} from "@/lib/workspace-paths";
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
const MAX_MESSAGES = 800;
// How long streamed text/reasoning deltas are buffered before a React commit.
// ~3 frames: fast enough to feel live, slow enough to absorb per-token events.
const STREAM_FLUSH_INTERVAL_MS = 48;
// How long a turn-end canonical history reconcile waits before reading the
// persisted transcript. The runtime's final transcript flush (tool results and
// turn metadata) races the done event by a few milliseconds, so an immediate
// read could catch the file mid-rewrite.
const TURN_END_RECONCILE_DELAY_MS = 250;
const RELEVANT_STREAMS = new Set([
"chat_text",
"chat_reasoning",
"chat_media",
"chat_queued_prompt_start",
"chat_tool_call_start",
"chat_tool_call_update",
"chat_tool_call_end",
"chat_core_log",
"chat_usage",
"chat_done",
]);
const BUSY_STATUSES = new Set<ChatSessionStatus>([
"starting",
"running",
"stopping",
]);
// Stale-stream fallback cadence for attached sessions (see the polling
// effect below): only poll after the live stream has been quiet this long,
// and re-check at this interval while it stays quiet.
const STALE_STREAM_QUIET_MS = 5_000;
const STALE_STREAM_POLL_INTERVAL_MS = 3_000;
type PendingToolOutput = {
text: string;
truncated: boolean;
detachable?: boolean;
};
// ---------------------------------------------------------------------------
// Helpers (pure, no hooks)
// ---------------------------------------------------------------------------
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function makeErrorChatMessage(
sid: string | null,
content: string,
): ChatMessage {
return {
id: makeId("error"),
sessionId: sid,
role: "error",
content,
createdAt: Date.now(),
};
}
function validateConfig(
config: ChatSessionConfig,
):
| { parsed: ChatSessionConfig; error: null }
| { parsed: null; error: string } {
const runtimeConfig = normalizeRuntimeConfig(config);
const result = ChatSessionConfigSchema.safeParse(runtimeConfig);
if (!result.success) {
return {
parsed: null,
error: result.error.issues.map((i) => i.message).join(", "),
};
}
const credentialError = resolveCredentialError(result.data);
if (credentialError) {
return { parsed: null, error: credentialError };
}
return { parsed: result.data, error: null };
}
function sliceMessages(msgs: ChatMessage[]): ChatMessage[] {
return msgs.length > MAX_MESSAGES ? msgs.slice(-MAX_MESSAGES) : msgs;
}
function sortMessagesChronologically(messages: ChatMessage[]): ChatMessage[] {
return [...messages].sort((left, right) => {
if (left.createdAt !== right.createdAt) {
return left.createdAt - right.createdAt;
}
return left.id.localeCompare(right.id);
});
}
function chunkCreatedAt(payload: AgentChunkEvent): number {
return payload.ts || Date.now();
}
function mergeHydratedMessagesWithLive(options: {
hydrated: ChatMessage[];
current: ChatMessage[];
sessionId: string;
hydrationStartedAt: number;
}): ChatMessage[] {
const { hydrated, current, sessionId, hydrationStartedAt } = options;
const liveDuringHydration = current.filter(
(message) =>
message.sessionId === sessionId &&
message.createdAt >= hydrationStartedAt,
);
if (liveDuringHydration.length === 0) {
return hydrated;
}
const hydratedKeyCounts = new Map<string, number>();
for (const message of hydrated) {
const key = `${message.role}\u0000${message.content}\u0000${message.reasoning ?? ""}`;
hydratedKeyCounts.set(key, (hydratedKeyCounts.get(key) ?? 0) + 1);
}
const nonDuplicateLive = liveDuringHydration.filter((message) => {
const key = `${message.role}\u0000${message.content}\u0000${message.reasoning ?? ""}`;
const hydratedCount = hydratedKeyCounts.get(key) ?? 0;
if (hydratedCount > 0) {
if (hydratedCount === 1) {
hydratedKeyCounts.delete(key);
} else {
hydratedKeyCounts.set(key, hydratedCount - 1);
}
return false;
}
return true;
});
return sortMessagesChronologically([...hydrated, ...nonDuplicateLive]);
}
function deriveLiveToolState(messages: ChatMessage[]): {
messageIds: Record<string, string>;
inputs: Record<string, unknown>;
} {
const messageIds: Record<string, string> = {};
const inputs: Record<string, unknown> = {};
for (const message of sortMessagesChronologically(messages)) {
const toolCallId = message.meta?.toolCallId;
if (!toolCallId) continue;
const hookEventName = message.meta?.hookEventName;
if (
hookEventName === "tool_call_end" ||
hookEventName === "history_tool_result"
) {
delete messageIds[toolCallId];
delete inputs[toolCallId];
continue;
}
if (
hookEventName !== "tool_call_start" &&
hookEventName !== "history_tool_use"
) {
continue;
}
messageIds[toolCallId] = message.id;
try {
const payload = JSON.parse(message.content) as unknown;
if (
payload &&
typeof payload === "object" &&
!Array.isArray(payload) &&
Object.hasOwn(payload, "input")
) {
inputs[toolCallId] = (payload as { input?: unknown }).input;
}
} catch {
// Routing only needs the ids. A malformed display payload must not
// prevent later progress and completion events from reaching the card.
}
}
return { messageIds, inputs };
}
function updateMessageById(
messages: ChatMessage[],
id: string,
updater: (msg: ChatMessage) => ChatMessage,
): ChatMessage[] {
let changed = false;
const next = messages.map((msg) => {
if (msg.id !== id) return msg;
changed = true;
return updater(msg);
});
return changed ? next : messages;
}
type SessionUsageSummary = {
tokensIn: number;
tokensOut: number;
cacheReadTokens: number;
totalCostUsd: number;
};
type TurnCostTracker = {
streamedCostUsd: number;
};
/**
* Read current context usage and cumulative cost from persisted chat messages.
*
* Each assistant message contains metrics for one model request. The latest
* request represents current context-window pressure; summing every request's
* input would instead measure lifetime traffic and make the ring saturate even
* after context compaction. Cost remains a session-wide sum.
*/
function summarizeSessionUsage(
messages: ChatMessage[],
): SessionUsageSummary | undefined {
let tokensIn: number | undefined;
let tokensOut: number | undefined;
let cacheReadTokens: number | undefined;
let totalCostUsd = 0;
let hasCost = false;
for (const message of messages) {
const meta = message.meta;
if (!meta) continue;
if (
typeof meta.inputTokens === "number" ||
typeof meta.outputTokens === "number" ||
typeof meta.cacheReadTokens === "number"
) {
tokensIn = meta.inputTokens ?? 0;
tokensOut = meta.outputTokens ?? 0;
cacheReadTokens = meta.cacheReadTokens ?? 0;
}
if (typeof meta.totalCost === "number") {
totalCostUsd += meta.totalCost;
hasCost = true;
}
}
if (tokensIn === undefined && tokensOut === undefined && !hasCost) {
return undefined;
}
return {
tokensIn: tokensIn ?? 0,
tokensOut: tokensOut ?? 0,
cacheReadTokens: cacheReadTokens ?? 0,
totalCostUsd,
};
}
// ---------------------------------------------------------------------------
// Core log dispatcher — avoids repeated if/else chains
// ---------------------------------------------------------------------------
const LOG_DISPATCH: Record<string, typeof console.info> = {
error: console.error,
warn: console.warn,
debug: console.debug,
};
// Core streams a steady flow of info/debug log chunks during a session.
// Serializing them through the console on the streaming hot path costs real
// CPU (DevTools keeps every entry alive), so anything below warn is dropped
// unless the user opts in via `localStorage.setItem("cline:debug-logs", "1")`.
let verboseCoreLogs: boolean | undefined;
function shouldLogVerboseCoreLogs(): boolean {
if (verboseCoreLogs === undefined) {
try {
verboseCoreLogs = window.localStorage.getItem("cline:debug-logs") === "1";
} catch {
verboseCoreLogs = false;
}
}
return verboseCoreLogs;
}
function dispatchCoreLog(chunk: string): void {
let parsed: CoreLogChunk | undefined;
try {
parsed = JSON.parse(chunk) as CoreLogChunk;
} catch {
if (shouldLogVerboseCoreLogs()) console.info("[core]", chunk);
return;
}
const level = parsed.level?.trim().toLowerCase() || "info";
if (level !== "error" && level !== "warn" && !shouldLogVerboseCoreLogs()) {
return;
}
const message = parsed.message?.trim() || chunk;
(LOG_DISPATCH[level] ?? console.info)("[core]", message, parsed.metadata);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useChatSession() {
const [sessionId, setSessionId] = useState<string | null>(null);
const [status, setStatus] = useState<ChatSessionStatus>("idle");
const [isHydratingSession, setIsHydratingSession] = useState(false);
const [config, setConfig] = useState<ChatSessionConfig>(getInitialChatConfig);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [rawTranscript, setRawTranscript] = useState("");
const [error, setError] = useState<string | null>(null);
const [toolCalls, setToolCalls] = useState(0);
const [tokensIn, setTokensIn] = useState(0);
const [tokensOut, setTokensOut] = useState(0);
const [cacheReadTokens, setCacheReadTokens] = useState(0);
const [totalCostUsd, setTotalCostUsd] = useState(0);
const [fileDiffs, setFileDiffs] = useState<SessionFileDiff[]>([]);
const [diffSummary, setDiffSummary] =
useState<SessionDiffSummary>(EMPTY_DIFF_SUMMARY);
const [activeAssistantMessageId, setActiveAssistantMessageId] = useState<
string | null
>(null);
const [hydratedHistorySessionId, setHydratedHistorySessionId] = useState<
string | null
>(null);
const [pendingToolApprovals, setPendingToolApprovals] = useState<
ToolApprovalRequestItem[]
>([]);
const [pendingAskQuestions, setPendingAskQuestions] = useState<
AskQuestionRequestItem[]
>([]);
const [promptsInQueue, setPromptsInQueue] = useState<PromptInQueue[]>([]);
const messagesRef = useRef<ChatMessage[]>([]);
// When the last chat_event chunk for the active session arrived. The
// stale-stream fallback below only polls while this stays quiet.
const lastLiveChunkAtRef = useRef(0);
const promptsInQueueRef = useRef<PromptInQueue[]>([]);
const liveToolMessageIdsRef = useRef<Record<string, string>>({});
const pendingToolOutputRef = useRef(new Map<string, PendingToolOutput>());
// Optimistic user bubbles whose prompt is still in flight, by message id.
// A chat_queued_prompt_start event may only re-key one of these — never a
// same-content message left over from an earlier turn (see the handler).
const outstandingOptimisticUserIdsRef = useRef<Set<string>>(new Set());
// Which optimistic bubble each queued user-message id re-keyed. The re-key
// updater consumes the outstanding set on its first run, so StrictMode's
// double-invoked updater needs this memo to reach the same result on its
// re-run instead of appending a duplicate bubble.
const rekeyedOptimisticIdByMessageIdRef = useRef<Record<string, string>>({});
const liveToolInputsRef = useRef<Record<string, unknown>>({});
const activeSessionIdRef = useRef<string | null>(null);
const activeAssistantMessageIdRef = useRef<string | null>(null);
const lastStreamIndexBySessionRef = useRef<Record<string, number>>({});
const abortedRef = useRef(false);
const abortFallbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const hydrationRequestIdRef = useRef(0);
const workspaceSelectionRequestRef = useRef(0);
const sessionStartPromiseRef = useRef<Promise<string> | null>(null);
const promptDispatchTailRef = useRef<Promise<void>>(Promise.resolve());
const activePromptSubmissionsRef = useRef(0);
const activeTurnCostTrackerRef = useRef<TurnCostTracker | null>(null);
const unpersistedCostUsdRef = useRef(0);
const lastPersistedCostUsdRef = useRef(0);
// Bumped whenever a new turn begins; lets async queue checks detect that
// their result is stale because another turn already started.
const turnEpochRef = useRef(0);
// Epoch at which the current turn reached a terminal status. Hub-projected
// chat_session_status events are asynchronous and a stale "running" can
// trail chat_done; when no new turn has started since the turn settled
// (epoch unchanged), such a "running" must not reopen the turn.
const turnSettledEpochRef = useRef(-1);
// Runtime status changes supersede local status captured by an abort.
const authoritativeStatusRevisionRef = useRef(0);
// Snapshot used to keep a late send response from overwriting a newer status.
const abortStatusRevisionRef = useRef(0);
// Last error-level core log per session, used to explain failed turns.
const lastCoreErrorBySessionRef = useRef<Record<string, string>>({});
const [chatTransportState, setChatTransportState] =
useState<ChatTransportState>(desktopClient.getTransportState());
const [chatTransportError, setChatTransportError] = useState<string | null>(
desktopClient.getTransportError(),
);
const persistedUsage = useMemo(
() =>
sessionId
? summarizeSessionUsage(
messages.filter((message) => message.sessionId === sessionId),
)
: undefined,
[messages, sessionId],
);
const persistedTokensIn = persistedUsage?.tokensIn;
const persistedTokensOut = persistedUsage?.tokensOut;
const persistedCacheReadTokens = persistedUsage?.cacheReadTokens;
const persistedTotalCostUsd = persistedUsage?.totalCostUsd;
// ---- Ref syncs ----
useEffect(() => {
activeSessionIdRef.current = sessionId;
}, [sessionId]);
useEffect(() => {
activeAssistantMessageIdRef.current = activeAssistantMessageId;
}, [activeAssistantMessageId]);
useEffect(() => {
messagesRef.current = messages;
}, [messages]);
useEffect(() => {
if (
persistedTokensIn === undefined ||
persistedTokensOut === undefined ||
persistedTotalCostUsd === undefined
) {
return;
}
setTokensIn(persistedTokensIn);
setTokensOut(persistedTokensOut);
setCacheReadTokens(persistedCacheReadTokens ?? 0);
// Move newly persisted spend out of the live ledger. Multiple queued turns
// may finish before their message metadata is hydrated, so this ledger is
// session-wide rather than tied only to the currently active turn.
const newlyPersistedCostUsd = Math.max(
0,
persistedTotalCostUsd - lastPersistedCostUsdRef.current,
);
unpersistedCostUsdRef.current = Math.max(
0,
unpersistedCostUsdRef.current - newlyPersistedCostUsd,
);
lastPersistedCostUsdRef.current = persistedTotalCostUsd;
setTotalCostUsd(persistedTotalCostUsd + unpersistedCostUsdRef.current);
}, [
persistedTokensIn,
persistedTokensOut,
persistedCacheReadTokens,
persistedTotalCostUsd,
]);
useEffect(() => {
promptsInQueueRef.current = promptsInQueue;
}, [promptsInQueue]);
const setWorkspacePath = useCallback((workspacePath: string): void => {
workspaceSelectionRequestRef.current += 1;
const normalized = workspacePath.trim();
setConfig((previous) => ({
...previous,
workspaceRoot: normalized,
cwd: normalized,
}));
}, []);
// ---- Shared state reset helpers ----
const clearLiveToolRefs = useCallback(() => {
liveToolMessageIdsRef.current = {};
liveToolInputsRef.current = {};
pendingToolOutputRef.current = new Map();
}, []);
const resetStreamDedupe = useCallback((targetSessionId?: string | null) => {
if (targetSessionId) {
delete lastStreamIndexBySessionRef.current[targetSessionId];
return;
}
lastStreamIndexBySessionRef.current = {};
}, []);
const clearAbortFallbackTimeout = useCallback(() => {
if (abortFallbackTimeoutRef.current) {
clearTimeout(abortFallbackTimeoutRef.current);
abortFallbackTimeoutRef.current = null;
}
}, []);
const resetCounters = useCallback(() => {
activeTurnCostTrackerRef.current = null;
unpersistedCostUsdRef.current = 0;
lastPersistedCostUsdRef.current = 0;
setToolCalls(0);
setTokensIn(0);
setTokensOut(0);
setCacheReadTokens(0);
setTotalCostUsd(0);
setFileDiffs([]);
setDiffSummary(EMPTY_DIFF_SUMMARY);
}, []);
const setErrorState = useCallback(
(msg: string, sid: string | null = null) => {
outstandingOptimisticUserIdsRef.current.clear();
rekeyedOptimisticIdByMessageIdRef.current = {};
setError(msg);
setStatus("error");
setMessages((prev) =>
sliceMessages([...prev, makeErrorChatMessage(sid, msg)]),
);
},
[],
);
// Surfaces a failed turn in the transcript. Some turns (for example the
// first prompt of a fresh session, which the runtime consumes from its
// queue) never resolve through the send() RPC, so without this the app
// fails silently: the user message sits alone with no response and no
// explanation. Skips appending when an error for this turn is already
// visible so the RPC path and the chat_done stream never double-report.
const appendTurnFailureMessage = useCallback(
(sid: string, detail: string) => {
const description =
detail.trim() || lastCoreErrorBySessionRef.current[sid]?.trim() || "";
// Deliberately avoids matching a bare "token": provider failures like
// "maximum context tokens exceeded" or rate-limit messages are not
// credential problems and must not point users at Settings → Models.
const looksCredentialRelated =
!description ||
/unauthorized|401|403|forbidden|api key|credential|authentication|sign in|auth token|access token|invalid token|expired token|token expired/i.test(
description,
);
const content = [
description
? `The run failed: ${description}`
: "The run failed before a response was produced.",
looksCredentialRelated
? "Check your model connection in Settings → Models (or sign in with Cline), then try again."
: "",
]
.filter(Boolean)
.join(" ");
setMessages((prev) => {
const sessionMessages = prev.filter(
(message) => message.sessionId === sid,
);
const last = sessionMessages[sessionMessages.length - 1];
if (last?.role === "error") {
return prev;
}
return sliceMessages([...prev, makeErrorChatMessage(sid, content)]);
});
setError(content);
},
[],
);
// Persisted history never contains UI-only error bubbles, so replacing the
// transcript with canonical messages wholesale would silently erase a
// failure explanation appended from chat_done moments earlier. Re-append
// the session's error messages after the canonical history — but only the
// ones still at the tail of the transcript (explaining the most recent
// turn). Re-pinning every historical error would resurface failures from
// long-completed turns at the bottom, out of chronological order, on
// every hydration.
const applyCanonicalHistory = useCallback(
(sid: string, historyMessages: ChatMessage[]) => {
setMessages((prev) => {
const sessionMessages = prev.filter(
(message) => message.sessionId === sid,
);
let tailErrorStart = sessionMessages.length;
while (
tailErrorStart > 0 &&
sessionMessages[tailErrorStart - 1]?.role === "error"
) {
tailErrorStart -= 1;
}
const preservedErrors = sessionMessages.slice(tailErrorStart);
if (preservedErrors.length === 0) {
return historyMessages;
}
return sliceMessages([...historyMessages, ...preservedErrors]);
});
},
[],
);
// Finalizes a turn that settled through the event stream rather than a
// blocking send() RPC. Queued turns (including the first prompt of a fresh
// session, which the runtime routes through its queue) resolve their send()
// RPC early with { queued: true }, so nothing else clears the streaming
// shimmer or reconciles the live-streamed content against the persisted
// transcript — without this, a turn whose deltas were incomplete stays
// visually "streaming" forever and only heals when a later non-queued send
// happens to rehydrate history.
const turnEndReconcileTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const finalizeSettledTurn = useCallback(
(sid: string) => {
if (activePromptSubmissionsRef.current > 0) {
// A blocking send() RPC is still in flight for this turn: its
// completion path applies the result text, rehydrates canonical
// history, and clears the streaming id itself. Clearing the id
// here would make that path mint a duplicate assistant bubble.
return;
}
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
const epoch = turnEpochRef.current;
if (turnEndReconcileTimerRef.current !== null) {
clearTimeout(turnEndReconcileTimerRef.current);
}
turnEndReconcileTimerRef.current = setTimeout(() => {
turnEndReconcileTimerRef.current = null;
void desktopClient
.invoke<ChatMessage[]>("read_session_messages", {
sessionId: sid,
maxMessages: MAX_MESSAGES,
})
.then((historyMessages) => {
if (
activeSessionIdRef.current !== sid ||
turnEpochRef.current !== epoch ||
activePromptSubmissionsRef.current > 0
) {
// A new turn started while the read was in flight; its
// own lifecycle owns the transcript from here on.
return;
}
if (
historyMessages.length === 0 ||
!historyMessages.some((message) => message.role === "assistant")
) {
// Persistence has not caught up: keep the live state
// rather than wiping it with an incomplete transcript.
return;
}
applyCanonicalHistory(sid, historyMessages);
})
.catch(() => {
// Keep optimistic state if the hydration read fails.
});
}, TURN_END_RECONCILE_DELAY_MS);
},
[applyCanonicalHistory],
);
useEffect(() => {
return () => {
if (turnEndReconcileTimerRef.current !== null) {
clearTimeout(turnEndReconcileTimerRef.current);
}
};
}, []);
// ---- Data fetching ----
const postSession = useCallback(async (body: Record<string, unknown>) => {
const request = { request: body };
if (body.action === "send") {
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
{ timeoutMs: null },
);
}
return await desktopClient.invoke<ChatSessionCommandResponse>(
"chat_session_command",
request,
);
}, []);
// Confirms a "still running because prompts are queued" status against the
// server. The local queue snapshot can be stale when the dequeue
// notification races the turn-completion event, which would otherwise
// leave the composer stuck on "Agent is working..." forever.
const verifyQueueStillBusy = useCallback(
(sid: string) => {
const epoch = turnEpochRef.current;
void postSession({ action: "pending_prompts", sessionId: sid })
.then((payload) => {
if (
activeSessionIdRef.current !== sid ||
turnEpochRef.current !== epoch
) {
return;
}
const items = Array.isArray(payload.promptsInQueue)
? payload.promptsInQueue
: [];
setPromptsInQueue(items);
if (items.length === 0) {
turnSettledEpochRef.current = turnEpochRef.current;
authoritativeStatusRevisionRef.current += 1;
setStatus((current) =>
current === "running" || current === "stopping"
? "completed"
: current,
);
finalizeSettledTurn(sid);
}
})
.catch(() => {
// Keep the last known state on transient transport failures.
});
},
[finalizeSettledTurn, postSession],
);
const refreshPromptsInQueue = useCallback(
async (targetSessionId: string | null) => {
if (!targetSessionId) {
setPromptsInQueue([]);
return;
}
try {
const payload = await postSession({
action: "pending_prompts",
sessionId: targetSessionId,
});
setPromptsInQueue(
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
} catch {
// Ignore queue refresh failures and keep the last known state.
}
},
[postSession],
);
const applyPromptsInQueue = useCallback((value: unknown) => {
if (!Array.isArray(value)) {
return;
}
setPromptsInQueue(value as PromptInQueue[]);
}, []);
const sessionDiffCwd = (config.cwd || config.workspaceRoot || "").trim();
const refreshSessionDiffSummary = useCallback(
async (targetSessionId: string) => {
try {
const events = await desktopClient.invoke<ChatSessionHookEvent[]>(
"read_session_hooks",
{ sessionId: targetSessionId, limit: MAX_MESSAGES },
);
const diffState = buildSessionDiffState(events, sessionDiffCwd);
setFileDiffs(diffState.fileDiffs);
setDiffSummary(diffState.summary);
setToolCalls(
events.filter(
(e) =>
e.hookEventName === "tool_call" || e.hookName === "tool_call",
).length,
);
} catch {
// Ignore in non-Tauri mode.
}
},
[sessionDiffCwd],
);
// ---- Message helpers ----
const addMessage = useCallback((message: ChatMessage) => {
setMessages((prev) => sliceMessages([...prev, message]));
}, []);
const shouldApplyStreamChunk = useCallback((payload: AgentChunkEvent) => {
const index = payload.index;
if (typeof index !== "number") {
return true;
}
const previous = lastStreamIndexBySessionRef.current[payload.sessionId];
if (previous !== undefined && index <= previous) {
return false;
}
lastStreamIndexBySessionRef.current[payload.sessionId] = index;
return true;
}, []);
const materializeToolMessagesFromResult = useCallback(
(options: {
sessionId: string;
turnStartedAt: number;
toolCalls: NonNullable<ChatApiResult["toolCalls"]>;
}) => {
const { sessionId: targetSessionId, turnStartedAt, toolCalls } = options;
if (toolCalls.length === 0) return;
setMessages((prev) => {
const hasLive = prev.some(
(m) =>
m.sessionId === targetSessionId &&
m.role === "tool" &&
m.createdAt >= turnStartedAt,
);
if (hasLive) return prev;
const next = [...prev];
for (const call of toolCalls) {
next.push({
id: makeId("tool"),
sessionId: targetSessionId,
role: "tool",
content: JSON.stringify({
toolName: call.name,
input: call.input,
result: call.error ? call.error : call.output,
isError: Boolean(call.error),
}),
createdAt: turnStartedAt + next.length,
meta: { toolName: call.name, hookEventName: "tool_call_end" },
});
}
return sliceMessages(sortMessagesChronologically(next));
});
},
[],
);
const appendMessageContent = useCallback((id: string, chunk: string) => {
if (!chunk) return;
setMessages((prev) =>
updateMessageById(prev, id, (msg) => {
const existing = msg.content;
if (existing.endsWith(chunk)) return msg;
const content = chunk.startsWith(existing)
? chunk
: `${existing}${chunk}`;
return { ...msg, content };
}),
);
}, []);
const appendMessageReasoning = useCallback(
(id: string, chunk: string, redacted = false) => {
if (!chunk && !redacted) return;
setMessages((prev) =>
updateMessageById(prev, id, (msg) => {
const reasoningChunk = chunk || (redacted ? "[redacted]" : "");
const existing = msg.reasoning ?? "";
if (reasoningChunk && existing.endsWith(reasoningChunk)) {
return redacted && !msg.reasoningRedacted
? { ...msg, reasoningRedacted: true }
: msg;
}
return {
...msg,
reasoning: `${existing}${reasoningChunk}`,
reasoningRedacted: msg.reasoningRedacted || redacted,
};
}),
);
},
[],
);
// ---- Stream coalescing ----
//
// Text/reasoning deltas and command chunks can arrive rapidly, and a React commit
// re-renders the whole conversation for every word. Deltas are buffered in
// refs and flushed on a short timer, so streaming costs at most ~20 commits
// per second regardless of token rate while still feeling live.
const pendingStreamTextRef = useRef(new Map<string, string>());
const pendingStreamReasoningRef = useRef(
new Map<string, { text: string; redacted: boolean }>(),
);
const pendingStreamTranscriptRef = useRef("");
const streamFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const flushPendingStream = useCallback(() => {
if (streamFlushTimerRef.current !== null) {
clearTimeout(streamFlushTimerRef.current);
streamFlushTimerRef.current = null;
}
const texts = pendingStreamTextRef.current;
if (texts.size > 0) {
pendingStreamTextRef.current = new Map();
for (const [id, chunk] of texts) {
appendMessageContent(id, chunk);
}
}
const reasonings = pendingStreamReasoningRef.current;
if (reasonings.size > 0) {
pendingStreamReasoningRef.current = new Map();
for (const [id, entry] of reasonings) {
appendMessageReasoning(id, entry.text, entry.redacted);
}
}
const toolOutputs = pendingToolOutputRef.current;
if (toolOutputs.size > 0) {
pendingToolOutputRef.current = new Map();
setMessages((prev) =>
prev.map((message) => {
const pending = toolOutputs.get(message.id);
if (!pending) return message;
const merged = appendCappedCommandOutput(
message.meta?.toolOutput ?? "",
pending.text,
);
const toolOutputTruncated = Boolean(
message.meta?.toolOutputTruncated ||
pending.truncated ||
merged.truncated,
);
const toolDetachable =
pending.detachable ?? message.meta?.toolDetachable;
if (
merged.output === (message.meta?.toolOutput ?? "") &&
toolOutputTruncated ===
Boolean(message.meta?.toolOutputTruncated) &&
toolDetachable === message.meta?.toolDetachable
) {
return message;
}
return {
...message,
meta: {
...message.meta,
toolOutput: merged.output,
toolOutputTruncated,
...(toolDetachable !== undefined ? { toolDetachable } : {}),
},
};
}),
);
}
const transcript = pendingStreamTranscriptRef.current;
if (transcript) {
pendingStreamTranscriptRef.current = "";
setRawTranscript((prev) => `${prev}${transcript}`);
}
}, [appendMessageContent, appendMessageReasoning]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFlushTimerRef.current !== null) {
return;
}
streamFlushTimerRef.current = setTimeout(() => {
streamFlushTimerRef.current = null;
flushPendingStream();
}, STREAM_FLUSH_INTERVAL_MS);
}, [flushPendingStream]);
const discardPendingStream = useCallback(() => {
if (streamFlushTimerRef.current !== null) {
clearTimeout(streamFlushTimerRef.current);
streamFlushTimerRef.current = null;
}
pendingStreamTextRef.current = new Map();
pendingStreamReasoningRef.current = new Map();
pendingToolOutputRef.current = new Map();
pendingStreamTranscriptRef.current = "";
}, []);
useEffect(() => {
return () => {
if (streamFlushTimerRef.current !== null) {
clearTimeout(streamFlushTimerRef.current);
}
};
}, []);
// ---- Process context ----
const applyProcessContext = useCallback(async () => {
const requestId = workspaceSelectionRequestRef.current;
try {
const ctx = await desktopClient.invoke<ProcessContext>(
"get_process_context",
);
if (ctx.homeDir) {
registerHostHomeDirectory(ctx.homeDir);
}
const rememberedWorkspace =
readWorkspaceSelectionFromWindow().lastWorkspace;
const validation = rememberedWorkspace
? await desktopClient
.invoke<{ valid?: boolean }>("validate_workspace_directory", {
path: rememberedWorkspace,
})
.catch(() => ({ valid: false }))
: { valid: false };
if (requestId !== workspaceSelectionRequestRef.current) {
return;
}
setConfig((prev) => {
const currentWorkspace = (prev.workspaceRoot || prev.cwd || "").trim();
const selectionChangedWhileLoading = Boolean(
currentWorkspace &&
normalizeWorkspacePath(currentWorkspace) !==
normalizeWorkspacePath(rememberedWorkspace),
);
const workspace = selectionChangedWhileLoading
? currentWorkspace
: validation.valid === true
? rememberedWorkspace
: ctx.workspaceRoot || ctx.cwd;
return {
...prev,
workspaceRoot: workspace,
cwd: workspace,
};
});
} catch {
// Ignore in non-Tauri mode.
}
}, []);
useEffect(() => {
void applyProcessContext();
}, [applyProcessContext]);
// ---- Diff / approval effects ----
useEffect(() => {
if (!sessionId) {
setFileDiffs([]);
setDiffSummary(EMPTY_DIFF_SUMMARY);
setPendingToolApprovals([]);
setPendingAskQuestions([]);
setPromptsInQueue([]);
return;
}
void refreshSessionDiffSummary(sessionId);
void refreshPromptsInQueue(sessionId);
}, [refreshPromptsInQueue, refreshSessionDiffSummary, sessionId]);
// Fallback for sessions with no tool events in the hook log (e.g. sessions
// recorded before tool_call/tool_result hook logging existed): rebuild the
// diff state from the tool messages themselves.
useEffect(() => {
if (!sessionId || fileDiffs.length > 0) {
return;
}
const events: SessionHookEvent[] = [];
for (const message of messages) {
if (message.sessionId !== sessionId || message.role !== "tool") {
continue;
}
let payload: {
toolName?: string;
input?: unknown;
result?: unknown;
isError?: boolean;
} | null = null;
try {
payload = JSON.parse(message.content);
} catch {
continue;
}
if (!payload?.toolName || payload.result == null || payload.isError) {
continue;
}
events.push({
hookName: "tool_result",
toolName: payload.toolName,
toolInput: payload.input,
toolOutput: payload.result,
});
}
if (events.length === 0) {
return;
}
const diffState = buildSessionDiffState(events, sessionDiffCwd);
if (diffState.fileDiffs.length === 0) {
return;
}
setFileDiffs(diffState.fileDiffs);
setDiffSummary(diffState.summary);
}, [sessionId, messages, fileDiffs.length, sessionDiffCwd]);
useEffect(() => {
const activeSessionId = sessionId;
setPendingToolApprovals([]);
setPendingAskQuestions([]);
if (!activeSessionId) {
return;
}
let cancelled = false;
void desktopClient
.invoke<ToolApprovalRequestItem[]>("poll_tool_approvals", {
sessionId: activeSessionId,
limit: 20,
})
.then((pending) => {
if (!cancelled) {
setPendingToolApprovals(pending);
}
})
.catch(() => {});
void desktopClient
.invoke<AskQuestionRequestItem[]>("poll_ask_questions", {
sessionId: activeSessionId,
})
.then((pending) => {
if (!cancelled) {
setPendingAskQuestions(pending);
}
})
.catch(() => {});
const unsubscribe = desktopClient.subscribe(
"tool_approval_state",
(payload) => {
if (!payload || typeof payload !== "object") return;
const record = payload as {
sessionId?: string;
items?: ToolApprovalRequestItem[];
};
if (record.sessionId !== activeSessionId) return;
setPendingToolApprovals(
Array.isArray(record.items) ? record.items : [],
);
},
);
return () => {
cancelled = true;
unsubscribe();
};
}, [sessionId]);
useEffect(() => {
return desktopClient.subscribe("ask_question_requested", (payload) => {
if (!payload || typeof payload !== "object") return;
const item = payload as AskQuestionRequestItem;
if (
item.sessionId !== activeSessionIdRef.current ||
!item.requestId ||
!item.question ||
!Array.isArray(item.options)
) {
return;
}
setPendingAskQuestions((prev) => {
if (prev.some((existing) => existing.requestId === item.requestId)) {
return prev;
}
return [...prev, item];
});
});
}, []);
useEffect(() => {
return desktopClient.subscribe("ask_question_answered", (payload) => {
if (!payload || typeof payload !== "object") return;
const requestId = String(
(payload as { requestId?: unknown }).requestId ?? "",
).trim();
if (!requestId) return;
setPendingAskQuestions((prev) =>
prev.filter((item) => item.requestId !== requestId),
);
});
}, []);
useEffect(() => {
return desktopClient.subscribe("ask_question_cancelled", (payload) => {
if (!payload || typeof payload !== "object") return;
const requestId = String(
(payload as { requestId?: unknown }).requestId ?? "",
).trim();
if (!requestId) return;
setPendingAskQuestions((prev) =>
prev.filter((item) => item.requestId !== requestId),
);
});
}, []);
useEffect(() => {
return desktopClient.subscribe("prompts_in_queue_state", (payload) => {
if (!payload || typeof payload !== "object") return;
const record = payload as {
sessionId?: string;
items?: PromptInQueue[];
};
if (record.sessionId !== activeSessionIdRef.current) return;
setPromptsInQueue(Array.isArray(record.items) ? record.items : []);
});
}, []);
// ---- Incoming chunk handler ----
const handleIncomingChunk = useCallback(
(payload: AgentChunkEvent) => {
if (!RELEVANT_STREAMS.has(payload.stream)) return;
if (!shouldApplyStreamChunk(payload)) return;
const listeningSessionId = activeSessionIdRef.current;
if (!listeningSessionId || payload.sessionId !== listeningSessionId) {
return;
}
lastLiveChunkAtRef.current = Date.now();
if (abortedRef.current && payload.stream !== "chat_done") {
return;
}
// --- Text stream (buffered) ---
if (payload.stream === "chat_text") {
let assistantId = activeAssistantMessageIdRef.current;
if (!assistantId) {
assistantId = makeId("assistant");
addMessage({
id: assistantId,
sessionId: listeningSessionId,
role: "assistant",
content: "",
createdAt: chunkCreatedAt(payload),
});
activeAssistantMessageIdRef.current = assistantId;
setActiveAssistantMessageId(assistantId);
}
const pending = pendingStreamTextRef.current;
pending.set(
assistantId,
(pending.get(assistantId) ?? "") + payload.chunk,
);
pendingStreamTranscriptRef.current += payload.chunk;
schedulePendingStreamFlush();
return;
}
if (payload.stream === "chat_reasoning") {
let assistantId = activeAssistantMessageIdRef.current;
if (!assistantId) {
assistantId = makeId("assistant");
addMessage({
id: assistantId,
sessionId: listeningSessionId,
role: "assistant",
content: "",
createdAt: chunkCreatedAt(payload),
});
activeAssistantMessageIdRef.current = assistantId;
setActiveAssistantMessageId(assistantId);
}
let parsed: ReasoningDeltaEvent = {};
try {
parsed = JSON.parse(payload.chunk) as ReasoningDeltaEvent;
} catch {
parsed = { text: payload.chunk };
}
if (parsed.text || parsed.redacted === true) {
const pending = pendingStreamReasoningRef.current;
const existing = pending.get(assistantId);
pending.set(assistantId, {
text: `${existing?.text ?? ""}${parsed.text ?? ""}`,
redacted: (existing?.redacted ?? false) || parsed.redacted === true,
});
schedulePendingStreamFlush();
}
return;
}
if (payload.stream === "chat_tool_call_update") {
let parsed: ToolCallUpdateEvent = {};
try {
parsed = JSON.parse(payload.chunk) as ToolCallUpdateEvent;
} catch {
return;
}
const toolCallId = parsed.toolCallId;
const messageId = toolCallId
? liveToolMessageIdsRef.current[toolCallId]
: undefined;
if (!messageId) return;
const update =
parsed.update &&
typeof parsed.update === "object" &&
!Array.isArray(parsed.update)
? (parsed.update as Record<string, unknown>)
: undefined;
if (!update) return;
const stream = update.stream;
const chunk =
(stream === "stdout" || stream === "stderr") &&
typeof update.chunk === "string"
? update.chunk
: "";
const detachable =
typeof update.detachable === "boolean"
? update.detachable
: undefined;
const sourceTruncated = update.truncated === true;
if (!chunk && detachable === undefined && !sourceTruncated) return;
const pending = pendingToolOutputRef.current;
const existing = pending.get(messageId);
const appended = appendCappedCommandOutput(existing?.text ?? "", chunk);
pending.set(messageId, {
text: appended.output,
truncated: Boolean(
existing?.truncated || appended.truncated || sourceTruncated,
),
detachable: detachable ?? existing?.detachable,
});
schedulePendingStreamFlush();
return;
}
// Any non-delta event (tool calls, prompt starts, done markers) must
// observe fully applied text, so drain the buffers first.
flushPendingStream();
if (payload.stream === "chat_media") {
let parsed: unknown;
try {
parsed = JSON.parse(payload.chunk);
} catch {
return;
}
const media = ChatMessageMediaSchema.safeParse(parsed);
if (!media.success) {
console.warn(
"[desktop:chat] Ignoring invalid generated media",
media.error.flatten(),
);
return;
}
setMessages((prev) => {
if (
prev.some((message) =>
message.media?.some((item) => item.id === media.data.id),
)
) {
return prev;
}
return sliceMessages([
...prev,
{
id: `media_${media.data.id}`,
sessionId: listeningSessionId,
role: "assistant",
content: "",
media: [media.data],
createdAt: chunkCreatedAt(payload),
},
]);
});
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
return;
}
if (payload.stream === "chat_queued_prompt_start") {
activeTurnCostTrackerRef.current = { streamedCostUsd: 0 };
turnEpochRef.current += 1;
// A new turn starts now: an error remembered from an earlier turn
// must not be attributed to this one if it fails without detail.
delete lastCoreErrorBySessionRef.current[listeningSessionId];
let parsed: {
promptId?: string;
prompt?: string;
attachmentCount?: number;
userImages?: string[];
} = {};
try {
parsed = JSON.parse(payload.chunk) as {
promptId?: string;
prompt?: string;
attachmentCount?: number;
userImages?: string[];
};
} catch {
parsed = { prompt: payload.chunk };
}
const prompt = parsed.prompt?.trim() ?? "";
const attachmentCount =
typeof parsed.attachmentCount === "number"
? parsed.attachmentCount
: 0;
const userImages = Array.isArray(parsed.userImages)
? parsed.userImages.filter(
(image): image is string => typeof image === "string",
)
: [];
const attachedFileCount = Math.max(
0,
attachmentCount - userImages.length,
);
const userLabel =
attachedFileCount > 0
? `${prompt}${prompt.length > 0 ? "\n\n" : ""}[attached ${attachedFileCount} file${attachedFileCount === 1 ? "" : "s"}]`
: prompt;
const promptId = parsed.promptId?.trim();
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
clearLiveToolRefs();
setStatus("running");
// The prompt just left the queue: drop it from the local snapshot
// immediately. Waiting for the next server snapshot leaves a
// window where turn completion still sees a stale busy queue and
// keeps the composer on "Agent is working..." forever.
setPromptsInQueue((prev) => {
if (prev.length === 0) {
return prev;
}
let index = promptId
? prev.findIndex((item) => item.id === promptId)
: -1;
if (index === -1) {
index = prev.findIndex(
(item) => item.prompt === userLabel || item.prompt === prompt,
);
}
if (index === -1) {
return prev;
}
const next = [...prev.slice(0, index), ...prev.slice(index + 1)];
promptsInQueueRef.current = next;
return next;
});
if (userLabel || userImages.length > 0) {
// Computed outside the updater: makeId() inside would mint a
// different id on each StrictMode re-invocation.
const userMessageId = promptId
? `queued_user_${promptId}`
: makeId("user");
setMessages((prev) => {
if (prev.some((message) => message.id === userMessageId)) {
return prev;
}
const images = toChatMessageImages(userImages, userMessageId);
// A prompt dispatched while the session was idle already has
// an optimistic bubble from the send path; when the runtime
// routes that prompt through its queue, this event describes
// the same prompt. Re-key the optimistic bubble instead of
// rendering the message a second time. Only bubbles whose
// prompt is still in flight are eligible — a same-content
// message left over from an earlier (e.g. cancelled) turn
// must stay distinct from the new submission.
// The first run of this updater consumes the outstanding id, so
// a StrictMode re-run against the same `prev` must recognize the
// bubble it already re-keyed rather than fall through to append.
const priorRekeyedId =
rekeyedOptimisticIdByMessageIdRef.current[userMessageId];
for (let i = prev.length - 1; i >= 0; i--) {
const candidate = prev[i];
if (candidate.role !== "user") {
break;
}
if (
candidate.content === userLabel &&
(outstandingOptimisticUserIdsRef.current.has(candidate.id) ||
candidate.id === priorRekeyedId)
) {
outstandingOptimisticUserIdsRef.current.delete(candidate.id);
rekeyedOptimisticIdByMessageIdRef.current[userMessageId] =
candidate.id;
const next = [...prev];
next[i] = {
...candidate,
id: userMessageId,
images: images.length > 0 ? images : candidate.images,
};
return sliceMessages(next);
}
}
return sliceMessages([
...prev,
{
id: userMessageId,
sessionId: listeningSessionId,
role: "user",
content: userLabel,
images: images.length > 0 ? images : undefined,
createdAt: chunkCreatedAt(payload),
},
]);
});
}
return;
}
// --- Core log ---
if (payload.stream === "chat_core_log") {
dispatchCoreLog(payload.chunk);
// Remember the latest error so a failed turn can explain itself:
// the runtime reports the underlying cause (e.g. an auth failure)
// here rather than on the turn-completion event.
try {
const parsed = JSON.parse(payload.chunk) as CoreLogChunk;
if (
parsed.level?.trim().toLowerCase() === "error" &&
parsed.message?.trim()
) {
lastCoreErrorBySessionRef.current[payload.sessionId] =
parsed.message.trim();
}
} catch {
// Unstructured logs carry no level; nothing to remember.
}
return;
}
if (payload.stream === "chat_usage") {
let usage: ChatUsageEvent;
try {
usage = JSON.parse(payload.chunk) as ChatUsageEvent;
} catch {
return;
}
if (typeof usage.inputTokens === "number") {
setTokensIn(usage.inputTokens);
}
if (typeof usage.outputTokens === "number") {
setTokensOut(usage.outputTokens);
}
if (typeof usage.cacheReadTokens === "number") {
setCacheReadTokens(usage.cacheReadTokens);
}
const cost = usage.cost;
if (typeof cost === "number") {
const tracker = activeTurnCostTrackerRef.current ?? {
streamedCostUsd: 0,
};
tracker.streamedCostUsd += cost;
activeTurnCostTrackerRef.current = tracker;
unpersistedCostUsdRef.current += cost;
setTotalCostUsd((previous) => previous + cost);
}
return;
}
if (payload.stream === "chat_done") {
// The turn is over: any optimistic bubble still registered was
// consumed by a direct send and must not be re-keyed by a later
// queued prompt that happens to repeat the same text. Clear
// inside an updater so that when this event lands in the same
// batch as its turn's chat_queued_prompt_start (fast-failing
// turns), the re-key updater queued by that event still sees the
// registered bubble and runs first. (Idempotent, so safe under
// StrictMode's double-invoked updaters.)
setMessages((prev) => {
outstandingOptimisticUserIdsRef.current.clear();
return prev;
});
clearLiveToolRefs();
// Prompts that the runtime consumed from the queue (for example the
// first prompt of a fresh session, which is queued while the
// interactive loop is still starting) never resolve through the
// send() RPC, so this stream event is the only turn-completion
// signal. Without it the composer stays on "Agent is working..."
// forever.
let doneReason = "";
let doneText = "";
try {
const parsed = JSON.parse(payload.chunk) as {
reason?: string;
text?: string;
};
doneReason = parsed.reason?.trim() ?? "";
doneText = typeof parsed.text === "string" ? parsed.text.trim() : "";
} catch {
// Missing reason still means the turn ended.
}
if (doneReason === "error") {
appendTurnFailureMessage(listeningSessionId, doneText);
}
// The remembered core error belongs to the turn that just ended.
// Turn-start events also clear it, but they can be lost across a
// transport interruption (websocket events are not replayed), so
// clearing on turn end too keeps a stale error from ever being
// attributed to a later turn's detail-less failure.
delete lastCoreErrorBySessionRef.current[listeningSessionId];
const nextStatus: ChatSessionStatus =
doneReason === "aborted"
? "cancelled"
: doneReason === "error"
? "failed"
: // Prompts still waiting in the queue mean the session is
// only between turns, not done: keep the composer in the
// busy state until the queue drains.
promptsInQueueRef.current.length > 0
? "running"
: "completed";
setStatus(nextStatus);
if (nextStatus === "running") {
verifyQueueStillBusy(listeningSessionId);
} else {
turnSettledEpochRef.current = turnEpochRef.current;
authoritativeStatusRevisionRef.current += 1;
finalizeSettledTurn(listeningSessionId);
}
return;
}
// --- Tool call start ---
if (payload.stream === "chat_tool_call_start") {
let parsed: ToolCallStartEvent = {};
try {
parsed = JSON.parse(payload.chunk) as ToolCallStartEvent;
} catch {
return;
}
const toolName = parsed.toolName ?? "unknown_tool";
const toolCallId = parsed.toolCallId ?? makeId("tool_call");
const messageId = makeId("tool");
liveToolMessageIdsRef.current[toolCallId] = messageId;
liveToolInputsRef.current[toolCallId] = parsed.input;
// Reset so text after this tool call gets a fresh assistant message
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
addMessage({
id: messageId,
sessionId: listeningSessionId,
role: "tool",
content: buildToolPayloadString({
toolName,
input: parsed.input,
output: null,
}),
createdAt: chunkCreatedAt(payload),
meta: {
toolName,
toolCallId,
hookEventName: "tool_call_start",
},
});
setToolCalls((prev) => prev + 1);
return;
}
// --- Tool call end ---
let parsed: ToolCallEndEvent = {};
try {
parsed = JSON.parse(payload.chunk) as ToolCallEndEvent;
} catch {
return;
}
const toolName = parsed.toolName ?? "unknown_tool";
const toolCallId = parsed.toolCallId;
const messageId = toolCallId
? liveToolMessageIdsRef.current[toolCallId]
: undefined;
const toolInput =
parsed.input ??
(toolCallId ? liveToolInputsRef.current[toolCallId] : undefined);
const toolPayload = buildToolPayloadString({
toolName,
input: toolInput,
output: parsed.output,
error: parsed.error,
});
if (toolCallId) {
delete liveToolMessageIdsRef.current[toolCallId];
delete liveToolInputsRef.current[toolCallId];
}
if (messageId) {
// Single setMessages call replaces content + updates meta together.
setMessages((prev) =>
updateMessageById(prev, messageId, (msg) => ({
...msg,
content: toolPayload,
meta: {
...msg.meta,
toolName,
toolCallId,
toolDetachable: false,
hookEventName: "tool_call_end",
},
})),
);
return;
}
// Ignore unmatched tool end events so stale completions from the
// previous turn do not get rendered under a newer streaming turn.
},
[
addMessage,
appendTurnFailureMessage,
clearLiveToolRefs,
finalizeSettledTurn,
flushPendingStream,
schedulePendingStreamFlush,
shouldApplyStreamChunk,
verifyQueueStillBusy,
],
);
// ---- Transport / event subscriptions ----
useEffect(() => {
const unsubscribeTransport = desktopClient.subscribeTransportState(
(state) => {
setChatTransportState(state);
setChatTransportError(desktopClient.getTransportError());
},
);
const unsubscribeEvents = desktopClient.subscribe(
"chat_event",
(payload) => {
if (payload && typeof payload === "object") {
handleIncomingChunk(payload as AgentChunkEvent);
}
},
);
return () => {
unsubscribeTransport();
unsubscribeEvents();
};
}, [handleIncomingChunk]);
useEffect(() => {
const unsubscribeStatus = desktopClient.subscribe(
"chat_session_status",
(payload) => {
if (!payload || typeof payload !== "object") {
return;
}
const record = payload as {
sessionId?: string;
status?: string;
};
const targetSessionId = record.sessionId?.trim();
if (
!targetSessionId ||
targetSessionId !== activeSessionIdRef.current
) {
return;
}
const nextStatus = record.status?.trim();
if (!nextStatus) {
return;
}
// Status events are projected asynchronously from the hub's
// session record and can deliver a stale "running" after the
// stream's chat_done already settled the turn. A genuinely new
// turn always bumps the epoch (chat_queued_prompt_start) or
// goes through the local submit paths, so while the epoch still
// equals the settled epoch a "running" here can only be stale.
if (
nextStatus === "running" &&
(abortedRef.current ||
turnEpochRef.current === turnSettledEpochRef.current)
) {
return;
}
authoritativeStatusRevisionRef.current += 1;
setStatus(nextStatus as ChatSessionStatus);
},
);
const unsubscribeEnded = desktopClient.subscribe(
"chat_session_ended",
(payload) => {
if (!payload || typeof payload !== "object") {
return;
}
const record = payload as {
sessionId?: string;
reason?: string;
};
const targetSessionId = record.sessionId?.trim();
if (
!targetSessionId ||
targetSessionId !== activeSessionIdRef.current
) {
return;
}
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
clearLiveToolRefs();
turnSettledEpochRef.current = turnEpochRef.current;
authoritativeStatusRevisionRef.current += 1;
setStatus((record.reason?.trim() || "idle") as ChatSessionStatus);
finalizeSettledTurn(targetSessionId);
},
);
return () => {
unsubscribeStatus();
unsubscribeEnded();
};
}, [clearLiveToolRefs, finalizeSettledTurn]);
// ---- Stale-stream fallback for attached sessions ----
// Scheduled/automation runs execute on a session host whose events are
// not projected through the hub's live pipeline (and with several hub
// daemons sharing cron.db, a different daemon can claim the run
// entirely), so an attached session can sit at "running" with a dead
// event stream — stuck on the thinking shimmer until a remount re-reads
// history. While an attached session is busy and the stream is quiet,
// poll canonical history and the session record so the transcript and
// status heal in place. A locally driven turn keeps chunks flowing, so
// the quiet-window guard keeps this fallback out of the way there.
useEffect(() => {
if (!sessionId || hydratedHistorySessionId !== sessionId) {
return;
}
if (!BUSY_STATUSES.has(status)) {
return;
}
let cancelled = false;
let polling = false;
const poll = async () => {
if (cancelled || polling) {
return;
}
if (Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS) {
return;
}
// An assistant bubble mid-stream means the live pipeline works;
// canonical history could lag behind it.
if (activeAssistantMessageIdRef.current) {
return;
}
// A locally driven turn is in flight (submit/queue bumps the epoch;
// settling closes it). Its optimistic user bubble carries the raw
// prompt while canonical history stores it wrapped in a
// user_input envelope, so replacing state mid-turn desyncs the
// rekey bookkeeping and the stream then appends a duplicate
// bubble. The fallback exists for externally driven runs
// (schedules, other clients) — stay inert until the local turn
// settles.
if (
turnEpochRef.current !== turnSettledEpochRef.current ||
outstandingOptimisticUserIdsRef.current.size > 0
) {
return;
}
polling = true;
try {
const pollStartedAt = Date.now();
const [historyMessages, record] = await Promise.all([
desktopClient
.invoke<ChatMessage[]>("read_session_messages", {
sessionId,
maxMessages: MAX_MESSAGES,
})
.catch(() => null),
desktopClient
.invoke<{ status?: string } | null>("get_discovered_session", {
sessionId,
})
.catch(() => null),
]);
if (
cancelled ||
activeSessionIdRef.current !== sessionId ||
// The live stream resumed (or a local turn started) while
// the poll was in flight; live state is fresher than the
// snapshot we just read.
Date.now() - lastLiveChunkAtRef.current < STALE_STREAM_QUIET_MS ||
activeAssistantMessageIdRef.current ||
turnEpochRef.current !== turnSettledEpochRef.current ||
outstandingOptimisticUserIdsRef.current.size > 0
) {
return;
}
if (Array.isArray(historyMessages) && historyMessages.length > 0) {
const mergedMessages = mergeHydratedMessagesWithLive({
hydrated: historyMessages,
current: messagesRef.current,
sessionId,
hydrationStartedAt: pollStartedAt,
});
// Same as hydration: canonical rows may have replaced live
// tool rows, so rebuild the tool routing keys or later
// tool events would append instead of updating in place.
const liveToolState = deriveLiveToolState(mergedMessages);
liveToolMessageIdsRef.current = liveToolState.messageIds;
liveToolInputsRef.current = liveToolState.inputs;
setMessages(mergedMessages);
}
// The record is the authority here: the sessions this poll
// serves have a live host maintaining their record, and it
// flips to a terminal status when the run ends. Transcript
// inference (inferHydratedChatStatus) would misread a mid-run
// snapshot ending on assistant narration as finished, hiding
// the working indicator and disarming this poll.
const nextStatus = record?.status?.trim();
if (nextStatus) {
const mappedStatus = mapSessionRecordStatus(
nextStatus as SessionHistoryStatus,
);
if (abortedRef.current && mappedStatus === "running") {
return;
}
authoritativeStatusRevisionRef.current += 1;
setStatus(mappedStatus);
}
} finally {
polling = false;
}
};
const interval = window.setInterval(
() => void poll(),
STALE_STREAM_POLL_INTERVAL_MS,
);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, [hydratedHistorySessionId, sessionId, status]);
// ---- Shared: start a new session via RPC ----
const startSession = useCallback(
async (
validatedConfig: ChatSessionConfig,
options: { preserveStatus?: boolean } = {},
): Promise<string> => {
const payload = await postSession({
action: "start",
config: validatedConfig,
});
const id = payload.sessionId;
if (!id) throw new Error("Missing session id from server");
const workspaceRoot =
payload.workspaceRoot?.trim() || validatedConfig.workspaceRoot.trim();
const cwd =
payload.cwd?.trim() || validatedConfig.cwd?.trim() || workspaceRoot;
if (!workspaceRoot || !cwd) {
throw new Error("Missing resolved workspace from server");
}
setSessionId(id);
// Mark idle — not running — so the first sendPrompt is not queued.
// The status transitions to "starting"/"running" once a prompt is
// actually dispatched.
if (!options.preserveStatus) {
setStatus("idle");
}
workspaceSelectionRequestRef.current += 1;
setConfig({
...validatedConfig,
cwd,
workspaceRoot,
});
setHydratedHistorySessionId(null);
return id;
},
[postSession],
);
// ---- Actions ----
const start = useCallback(
async (nextConfig: ChatSessionConfig) => {
const validation = validateConfig(nextConfig);
if (!validation.parsed) {
setErrorState(validation.error);
return;
}
const parsed = validation.parsed;
setError(null);
setStatus("starting");
setIsHydratingSession(false);
abortedRef.current = false;
clearAbortFallbackTimeout();
discardPendingStream();
setMessages([]);
setRawTranscript("");
resetCounters();
setConfig(parsed);
setHydratedHistorySessionId(null);
setPromptsInQueue([]);
try {
const id = await startSession(parsed);
addMessage({
id: makeId("status"),
sessionId: id,
role: "status",
content: `Session started: ${id}`,
createdAt: Date.now(),
});
} catch (err) {
setErrorState(errorMessage(err));
}
},
[
addMessage,
clearAbortFallbackTimeout,
discardPendingStream,
resetCounters,
setErrorState,
startSession,
],
);
const sendPrompt = useCallback(
async (prompt: string, attachedFiles: File[] = []) => {
const trimmed = prompt.trim();
if (!trimmed && attachedFiles.length === 0) return;
setError(null);
setIsHydratingSession(false);
abortedRef.current = false;
clearAbortFallbackTimeout();
const pendingSessionStart = sessionStartPromiseRef.current;
let activeSessionId = sessionId ?? activeSessionIdRef.current;
const validation = validateConfig(config);
if (!validation.parsed) {
setErrorState(validation.error, activeSessionId);
return;
}
const parsed = validation.parsed;
const hasEarlierPromptSubmission = activePromptSubmissionsRef.current > 0;
activePromptSubmissionsRef.current += 1;
let promptSubmissionFinished = false;
const finishPromptSubmission = () => {
if (promptSubmissionFinished) return;
promptSubmissionFinished = true;
activePromptSubmissionsRef.current = Math.max(
0,
activePromptSubmissionsRef.current - 1,
);
};
const precedingPromptDispatch = promptDispatchTailRef.current;
let resolvePromptDispatch: (() => void) | undefined;
const ownPromptDispatch = new Promise<void>((resolve) => {
resolvePromptDispatch = resolve;
});
const promptDispatchTail = precedingPromptDispatch.then(
() => ownPromptDispatch,
);
promptDispatchTailRef.current = promptDispatchTail;
let promptDispatchReleased = false;
const releasePromptDispatch = () => {
if (promptDispatchReleased) return;
promptDispatchReleased = true;
resolvePromptDispatch?.();
};
void promptDispatchTail.then(() => {
if (promptDispatchTailRef.current === promptDispatchTail) {
promptDispatchTailRef.current = Promise.resolve();
}
});
const now = Date.now();
const serializedAttachmentsTask = serializeAttachments(
attachedFiles,
).then(
(attachments) => ({ ok: true as const, attachments }),
(error: unknown) => ({ ok: false as const, error }),
);
const attachedFileCount = attachedFiles.filter(
(file) => !file.type.startsWith("image/"),
).length;
const userLabel =
attachedFileCount > 0
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFileCount} file${attachedFileCount === 1 ? "" : "s"}]`
: trimmed;
const shouldQueue =
Boolean(activeSessionId) &&
(hasEarlierPromptSubmission ||
Boolean(pendingSessionStart) ||
BUSY_STATUSES.has(status));
const turnCostTracker: TurnCostTracker | undefined = shouldQueue
? undefined
: { streamedCostUsd: 0 };
const optimisticQueuedPromptId = shouldQueue
? makeId("queued_prompt")
: null;
const optimisticUserMessageId = shouldQueue ? null : makeId("user");
const plannedSessionId = activeSessionId ?? makeId("session");
if (optimisticUserMessageId) {
outstandingOptimisticUserIdsRef.current.add(optimisticUserMessageId);
addMessage({
id: optimisticUserMessageId,
sessionId: plannedSessionId,
role: "user",
content: userLabel,
createdAt: now,
});
turnEpochRef.current += 1;
// Fresh turn: forget any error remembered from a previous turn so
// a detail-less failure of this turn cannot pick it up.
delete lastCoreErrorBySessionRef.current[plannedSessionId];
activeSessionIdRef.current = plannedSessionId;
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
clearLiveToolRefs();
setStatus("starting");
} else if (optimisticQueuedPromptId) {
// Invalidate any in-flight server queue double-check: its snapshot
// predates this prompt and must not wipe the optimistic queue
// entry or flip the session to completed.
turnEpochRef.current += 1;
setPromptsInQueue((prev) => [
...prev,
{
id: optimisticQueuedPromptId,
prompt: userLabel,
steer: false,
},
]);
}
let sendTask: ReturnType<typeof postSession> | null = null;
// The turn epoch at send-RPC dispatch time. chat_queued_prompt_start
// bumps the epoch when the runtime starts consuming a queued prompt,
// so a mismatch when the send response arrives means the stream has
// already advanced this turn's lifecycle past the snapshot carried
// by the response.
let turnEpochAtDispatch = turnEpochRef.current;
try {
if (pendingSessionStart) {
try {
activeSessionId = await pendingSessionStart;
} catch (err) {
if (optimisticQueuedPromptId) {
setPromptsInQueue((prev) =>
prev.filter((item) => item.id !== optimisticQueuedPromptId),
);
}
setErrorState(errorMessage(err), activeSessionId);
finishPromptSubmission();
return;
}
} else if (
activeSessionId &&
hydratedHistorySessionId === activeSessionId
) {
const startPromise = startSession(
{
...parsed,
sessionId: activeSessionId,
},
{ preserveStatus: true },
);
sessionStartPromiseRef.current = startPromise;
try {
activeSessionId = await startPromise;
} catch (err) {
setErrorState(errorMessage(err), activeSessionId);
finishPromptSubmission();
return;
} finally {
if (sessionStartPromiseRef.current === startPromise) {
sessionStartPromiseRef.current = null;
}
}
}
if (!activeSessionId) {
const startPromise = startSession(
{
...parsed,
sessionId: plannedSessionId,
},
{ preserveStatus: true },
);
sessionStartPromiseRef.current = startPromise;
try {
activeSessionId = await startPromise;
} catch (err) {
if (activeSessionIdRef.current === plannedSessionId) {
activeSessionIdRef.current = null;
}
setErrorState(errorMessage(err));
finishPromptSubmission();
return;
} finally {
if (sessionStartPromiseRef.current === startPromise) {
sessionStartPromiseRef.current = null;
}
}
}
const serializedAttachmentsResult = await serializedAttachmentsTask;
if (!serializedAttachmentsResult.ok) {
setErrorState(
errorMessage(serializedAttachmentsResult.error),
activeSessionId,
);
finishPromptSubmission();
return;
}
const serializedAttachments = serializedAttachmentsResult.attachments;
const hasAttachments =
serializedAttachments.userImages.length > 0 ||
serializedAttachments.userFiles.length > 0;
if (
optimisticUserMessageId &&
serializedAttachments.userImages.length > 0
) {
const images = toChatMessageImages(
serializedAttachments.userImages,
optimisticUserMessageId,
);
if (images.length > 0) {
setMessages((prev) =>
updateMessageById(prev, optimisticUserMessageId, (message) => ({
...message,
images,
})),
);
}
}
if (!shouldQueue) {
activeSessionIdRef.current = activeSessionId;
activeTurnCostTrackerRef.current = turnCostTracker ?? null;
setStatus("starting");
}
await precedingPromptDispatch;
turnEpochAtDispatch = turnEpochRef.current;
sendTask = postSession({
action: "send",
sessionId: activeSessionId,
prompt: trimmed,
delivery: shouldQueue ? "queue" : undefined,
config: parsed,
attachments: hasAttachments ? serializedAttachments : undefined,
});
} finally {
releasePromptDispatch();
}
if (!sendTask) {
finishPromptSubmission();
return;
}
let abortedReconcileEpoch: number | undefined;
const settleAbortedSend = () => {
if (!abortedRef.current) return false;
if (
authoritativeStatusRevisionRef.current ===
abortStatusRevisionRef.current
) {
abortedReconcileEpoch = turnEpochRef.current;
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("cancelled");
}
return true;
};
try {
const payload = await sendTask;
if (payload.ok && payload.queued) {
if (settleAbortedSend()) return;
if (turnEpochRef.current !== turnEpochAtDispatch) {
// The runtime already started consuming a queued prompt
// (chat_queued_prompt_start bumped the epoch) while this
// response was in flight: its queue snapshot predates the
// dequeue and its "still queued" status is obsolete.
// Applying them would resurrect a phantom queue entry and
// could flip a turn that already completed via chat_done
// back to "running" — wedging the composer forever. The
// stream (chat_queued_prompt_start/chat_done and
// prompts_in_queue_state) is authoritative from here on.
return;
}
applyPromptsInQueue(payload.promptsInQueue);
setStatus("running");
return;
}
const result = payload.result as ChatApiResult | undefined;
applyPromptsInQueue(payload.promptsInQueue);
if (settleAbortedSend()) return;
// On a failed run the runtime reports the error string in
// result.text — it is not assistant content and must not be
// rendered as an assistant bubble (canonical rehydration would
// silently wipe it, leaving the user with a blank chat).
const isErrorResult = result?.finishReason === "error";
const assistantText = isErrorResult ? "" : (result?.text ?? "").trim();
const fallbackAssistantTurn = extractAssistantTurnDataFromRpcMessages(
result?.messages,
);
const rawAssistantText = assistantText || fallbackAssistantTurn.text;
const resolvedAssistantText = rawAssistantText;
if (resolvedAssistantText) {
const assistantMessageId =
activeAssistantMessageIdRef.current ?? makeId("assistant");
activeAssistantMessageIdRef.current = assistantMessageId;
setActiveAssistantMessageId(assistantMessageId);
setMessages((prev) => {
const updated = updateMessageById(
prev,
assistantMessageId,
(msg) => ({ ...msg, content: resolvedAssistantText }),
);
if (updated !== prev) return updated;
return sliceMessages([
...prev,
{
id: assistantMessageId,
sessionId: activeSessionId,
role: "assistant" as const,
content: resolvedAssistantText,
reasoning: fallbackAssistantTurn.reasoning || undefined,
reasoningRedacted:
fallbackAssistantTurn.reasoningRedacted || undefined,
createdAt: now + 1,
},
]);
});
} else if (
fallbackAssistantTurn.reasoning ||
fallbackAssistantTurn.reasoningRedacted
) {
const assistantMessageId =
activeAssistantMessageIdRef.current ?? makeId("assistant");
activeAssistantMessageIdRef.current = assistantMessageId;
setActiveAssistantMessageId(assistantMessageId);
setMessages((prev) => {
const updated = updateMessageById(
prev,
assistantMessageId,
(msg) => ({
...msg,
reasoning: fallbackAssistantTurn.reasoning || msg.reasoning,
reasoningRedacted:
fallbackAssistantTurn.reasoningRedacted ||
msg.reasoningRedacted,
}),
);
if (updated !== prev) return updated;
return sliceMessages([
...prev,
{
id: assistantMessageId,
sessionId: activeSessionId,
role: "assistant" as const,
content: "",
reasoning: fallbackAssistantTurn.reasoning || undefined,
reasoningRedacted:
fallbackAssistantTurn.reasoningRedacted || undefined,
createdAt: now + 1,
},
]);
});
}
const fallbackImages = fallbackAssistantTurn.images.flatMap(
(candidate) => {
const image = ChatMessageImageSchema.safeParse({
id: makeId("generated_image"),
...candidate,
});
return image.success ? [image.data] : [];
},
);
if (fallbackImages.length > 0) {
const assistantMessageId =
activeAssistantMessageIdRef.current ?? makeId("assistant");
activeAssistantMessageIdRef.current = assistantMessageId;
setActiveAssistantMessageId(assistantMessageId);
setMessages((prev) => {
const updated = updateMessageById(
prev,
assistantMessageId,
(message) => {
const existing = message.images ?? [];
const additions = fallbackImages.filter(
(image) =>
!existing.some(
(candidate) =>
candidate.data === image.data &&
candidate.mediaType === image.mediaType,
),
);
return additions.length > 0
? { ...message, images: [...existing, ...additions] }
: message;
},
);
if (updated !== prev) return updated;
return sliceMessages([
...prev,
{
id: assistantMessageId,
sessionId: activeSessionId,
role: "assistant" as const,
content: resolvedAssistantText,
images: fallbackImages,
reasoning: fallbackAssistantTurn.reasoning || undefined,
reasoningRedacted:
fallbackAssistantTurn.reasoningRedacted || undefined,
createdAt: now + 1,
},
]);
});
}
const fallbackMedia = fallbackAssistantTurn.media;
if (fallbackMedia.length > 0) {
setMessages((prev) => {
const knownIds = new Set(
prev.flatMap((message) =>
(message.media ?? []).map((media) => media.id),
),
);
const additions = fallbackMedia.filter(
(media) => !knownIds.has(media.id),
);
return additions.length === 0
? prev
: sliceMessages([
...prev,
...additions.map((media, index) => ({
id: `media_${media.id}`,
sessionId: activeSessionId,
role: "assistant" as const,
content: "",
media: [media],
createdAt: now + 2 + index,
})),
]);
});
}
const hasFallbackAssistantTurn =
Boolean(resolvedAssistantText) ||
Boolean(fallbackAssistantTurn.reasoning) ||
fallbackAssistantTurn.reasoningRedacted ||
fallbackImages.length > 0 ||
fallbackMedia.length > 0;
if (!hasFallbackAssistantTurn) {
// Recovery: load canonical messages if transport missed result text.
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: activeSessionId, maxMessages: MAX_MESSAGES },
);
if (historyMessages.length > 0) {
applyCanonicalHistory(activeSessionId, historyMessages);
}
} catch {
// Keep optimistic state if hydration read fails.
}
}
if (Array.isArray(result?.toolCalls) && result.toolCalls.length > 0) {
materializeToolMessagesFromResult({
sessionId: activeSessionId,
turnStartedAt: now,
toolCalls: result.toolCalls,
});
}
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: activeSessionId, maxMessages: MAX_MESSAGES },
);
const hasCanonicalAssistantTurn = historyMessages.some(
(message) => message.role === "assistant",
);
if (
historyMessages.length > 0 &&
(!hasFallbackAssistantTurn || hasCanonicalAssistantTurn)
) {
const assistantIndex = historyMessages.findLastIndex(
(message) => message.role === "assistant",
);
const canonicalMessages =
assistantIndex >= 0 && hasFallbackAssistantTurn
? historyMessages.map((message, index) => {
if (index !== assistantIndex) return message;
const existingImages = message.images ?? [];
const missingImages = fallbackImages.filter(
(image) =>
!existingImages.some(
(candidate) =>
candidate.data === image.data &&
candidate.mediaType === image.mediaType,
),
);
return {
...message,
content: message.content || resolvedAssistantText,
reasoning:
message.reasoning ||
fallbackAssistantTurn.reasoning ||
undefined,
reasoningRedacted:
message.reasoningRedacted ||
fallbackAssistantTurn.reasoningRedacted ||
undefined,
images:
missingImages.length > 0
? [...existingImages, ...missingImages]
: message.images,
};
})
: historyMessages;
applyCanonicalHistory(activeSessionId, canonicalMessages);
}
} catch {
// Keep optimistic state if canonical hydration fails.
}
// Token / cost bookkeeping
const inputTokens = result?.usage?.inputTokens ?? result?.inputTokens;
if (typeof inputTokens === "number") {
setTokensIn(inputTokens);
}
const outputTokens =
result?.usage?.outputTokens ?? result?.outputTokens;
if (typeof outputTokens === "number") {
setTokensOut(outputTokens);
}
const resultCacheReadTokens =
result?.usage?.cacheReadTokens ?? result?.cacheReadTokens;
if (typeof resultCacheReadTokens === "number") {
setCacheReadTokens(resultCacheReadTokens);
}
const totalCost =
typeof result?.usage?.totalCost === "number"
? result.usage.totalCost
: undefined;
const streamedTurnCostUsd = turnCostTracker?.streamedCostUsd ?? 0;
if (activeTurnCostTrackerRef.current === turnCostTracker) {
activeTurnCostTrackerRef.current = null;
}
if (typeof totalCost === "number") {
const unstreamedCostUsd = totalCost - streamedTurnCostUsd;
unpersistedCostUsdRef.current += unstreamedCostUsd;
setTotalCostUsd((previous) => previous + unstreamedCostUsd);
}
const assistantMessageId = activeAssistantMessageIdRef.current;
if (
assistantMessageId &&
(typeof inputTokens === "number" ||
typeof outputTokens === "number" ||
typeof resultCacheReadTokens === "number" ||
typeof totalCost === "number")
) {
setMessages((prev) =>
updateMessageById(prev, assistantMessageId, (msg) => ({
...msg,
meta: {
...(msg.meta ?? {}),
inputTokens:
typeof inputTokens === "number"
? inputTokens
: msg.meta?.inputTokens,
outputTokens:
typeof outputTokens === "number"
? outputTokens
: msg.meta?.outputTokens,
cacheReadTokens:
typeof resultCacheReadTokens === "number"
? resultCacheReadTokens
: msg.meta?.cacheReadTokens,
totalCost:
typeof totalCost === "number"
? totalCost
: msg.meta?.totalCost,
providerId: config.provider,
modelId: config.model,
},
})),
);
}
const hasQueuedFollowUps =
Array.isArray(payload.promptsInQueue) &&
payload.promptsInQueue.length > 0;
if (settleAbortedSend()) return;
if (result?.finishReason === "error") {
// On a failed run result.text is the runtime's error string
// (never assistant content — see isErrorResult above), so it
// is the best failure detail available. The reporter dedupes
// against the chat_done stream path.
const runError = (result?.text ?? "").trim();
const toolError = Array.isArray(result?.toolCalls)
? result.toolCalls.find((c) => c.error)?.error
: undefined;
appendTurnFailureMessage(
activeSessionId,
runError || toolError?.trim() || "",
);
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("failed");
} else if (result?.finishReason === "aborted") {
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("cancelled");
} else if (hasQueuedFollowUps) {
setStatus("running");
} else {
turnSettledEpochRef.current = turnEpochRef.current;
setStatus("completed");
}
void refreshSessionDiffSummary(activeSessionId);
} catch (err) {
if (settleAbortedSend()) return;
if (optimisticQueuedPromptId) {
setPromptsInQueue((prev) =>
prev.filter((item) => item.id !== optimisticQueuedPromptId),
);
}
setErrorState(errorMessage(err), activeSessionId);
} finally {
clearAbortFallbackTimeout();
if (!shouldQueue) {
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
clearLiveToolRefs();
}
finishPromptSubmission();
if (
abortedReconcileEpoch !== undefined &&
activeSessionIdRef.current === activeSessionId &&
turnEpochRef.current === abortedReconcileEpoch
) {
finalizeSettledTurn(activeSessionId);
}
}
},
[
addMessage,
appendTurnFailureMessage,
applyCanonicalHistory,
applyPromptsInQueue,
clearAbortFallbackTimeout,
clearLiveToolRefs,
config,
finalizeSettledTurn,
hydratedHistorySessionId,
materializeToolMessagesFromResult,
refreshSessionDiffSummary,
sessionId,
setErrorState,
startSession,
status,
postSession,
],
);
const respondToolApproval = useCallback(
async (requestId: string, approved: boolean) => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId) return;
await desktopClient.invoke("respond_tool_approval", {
sessionId: activeSessionId,
requestId,
approved,
reason: approved
? undefined
: "Tool call rejected from desktop approval prompt",
});
setPendingToolApprovals((prev) =>
prev.filter((item) => item.requestId !== requestId),
);
},
[],
);
const approveToolApproval = useCallback(
(requestId: string) => respondToolApproval(requestId, true),
[respondToolApproval],
);
const rejectToolApproval = useCallback(
(requestId: string) => respondToolApproval(requestId, false),
[respondToolApproval],
);
const answerAskQuestion = useCallback(
async (requestId: string, answer: string) => {
await desktopClient.invoke("respond_ask_question", {
requestId,
answer,
});
setPendingAskQuestions((prev) =>
prev.filter((item) => item.requestId !== requestId),
);
},
[],
);
const restoreCheckpoint = useCallback(
async (checkpointRunCount: number) => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId) {
throw new Error("No active session to restore");
}
if (BUSY_STATUSES.has(status)) {
throw new Error("Wait for the current turn to finish before undoing");
}
clearAbortFallbackTimeout();
setError(null);
setIsHydratingSession(false);
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
setPendingToolApprovals([]);
setPendingAskQuestions([]);
setPromptsInQueue([]);
clearLiveToolRefs();
const payload = (await postSession({
action: "restore_checkpoint",
sessionId: activeSessionId,
checkpointRunCount,
config,
})) as {
sessionId?: string;
};
const nextSessionId =
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
if (!nextSessionId) {
throw new Error("Checkpoint restore did not return a new session id");
}
const nextMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{
sessionId: nextSessionId,
maxMessages: MAX_MESSAGES,
},
);
setSessionId(nextSessionId);
activeSessionIdRef.current = nextSessionId;
setMessages(nextMessages);
setRawTranscript("");
setStatus(nextMessages.length > 0 ? "completed" : "idle");
resetCounters();
void refreshSessionDiffSummary(nextSessionId);
void refreshPromptsInQueue(nextSessionId);
},
[
clearAbortFallbackTimeout,
clearLiveToolRefs,
config,
postSession,
refreshPromptsInQueue,
refreshSessionDiffSummary,
resetCounters,
status,
],
);
const abort = useCallback(async () => {
if (!sessionId) return;
const fallbackStatus: ChatSessionStatus =
status === "stopping" ? "running" : status;
const statusRevisionAtAbort = authoritativeStatusRevisionRef.current;
abortStatusRevisionRef.current = statusRevisionAtAbort;
const restoreFallbackStatus = () => {
abortedRef.current = false;
clearAbortFallbackTimeout();
if (
activeSessionIdRef.current === sessionId &&
authoritativeStatusRevisionRef.current === statusRevisionAtAbort
) {
setStatus(fallbackStatus);
}
};
abortedRef.current = true;
setStatus("stopping");
clearAbortFallbackTimeout();
abortFallbackTimeoutRef.current = setTimeout(() => {
if (
abortedRef.current &&
activeSessionIdRef.current === sessionId &&
authoritativeStatusRevisionRef.current === statusRevisionAtAbort
) {
setStatus("cancelled");
}
abortFallbackTimeoutRef.current = null;
}, 2000);
try {
const response = await postSession({ action: "abort", sessionId });
if (!response.ok) {
restoreFallbackStatus();
}
} catch {
restoreFallbackStatus();
}
}, [clearAbortFallbackTimeout, postSession, sessionId, status]);
const proceedWhileRunning = useCallback(
async (targetSessionId: string, toolCallId?: string) => {
const normalizedSessionId = targetSessionId.trim();
if (!normalizedSessionId) {
throw new Error("No active session.");
}
const response = await desktopClient.invoke<{ detachedCount?: number }>(
"proceed_while_running",
{
sessionId: normalizedSessionId,
...(toolCallId ? { toolCallId } : {}),
},
);
if ((response.detachedCount ?? 0) < 1) {
throw new Error("The command finished before it could be detached.");
}
},
[],
);
const reset = useCallback(async () => {
const activeSessionId = sessionId;
setSessionId(null);
setStatus("idle");
setIsHydratingSession(false);
abortedRef.current = false;
clearAbortFallbackTimeout();
discardPendingStream();
setMessages([]);
setRawTranscript("");
setError(null);
resetCounters();
setConfig((prev) => {
// Re-seed the composer from the remembered defaults (the same
// source a freshly mounted thread uses) so a reset after viewing
// a historical session does not retain that session's
// provider/model for the next chat.
const initial = getInitialChatConfig();
return {
...prev,
sessionId: undefined,
provider: initial.provider,
model: initial.model,
apiKey:
prev.provider === initial.provider ? prev.apiKey : initial.apiKey,
};
});
activeSessionIdRef.current = null;
sessionStartPromiseRef.current = null;
outstandingOptimisticUserIdsRef.current.clear();
rekeyedOptimisticIdByMessageIdRef.current = {};
lastCoreErrorBySessionRef.current = {};
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
setHydratedHistorySessionId(null);
setPendingToolApprovals([]);
setPendingAskQuestions([]);
setPromptsInQueue([]);
clearLiveToolRefs();
if (activeSessionId) {
try {
await postSession({ action: "reset", sessionId: activeSessionId });
} catch {
// Best-effort reset path.
}
}
}, [
sessionId,
clearAbortFallbackTimeout,
discardPendingStream,
postSession,
resetCounters,
clearLiveToolRefs,
]);
const hydrateSession = useCallback(
async (session: SessionHistoryItem) => {
const requestId = hydrationRequestIdRef.current + 1;
const hydrationStartedAt = Date.now();
hydrationRequestIdRef.current = requestId;
setError(null);
setStatus("starting");
setIsHydratingSession(true);
resetStreamDedupe(session.sessionId);
abortedRef.current = false;
clearAbortFallbackTimeout();
setSessionId(session.sessionId);
setConfig((prev) => ({
...prev,
sessionId: session.sessionId,
provider: session.provider || prev.provider,
model: session.model || prev.model,
workspaceRoot: session.workspaceRoot || prev.workspaceRoot,
cwd: session.workspaceRoot || session.cwd || prev.cwd,
}));
activeSessionIdRef.current = session.sessionId;
activeAssistantMessageIdRef.current = null;
setActiveAssistantMessageId(null);
// A freshly hydrated session has no local turn in flight; without
// this the mount defaults (epoch 0, settled -1) read as an open
// turn and keep the stale-stream fallback inert forever.
turnSettledEpochRef.current = turnEpochRef.current;
setHydratedHistorySessionId(session.sessionId);
setPendingToolApprovals([]);
setPendingAskQuestions([]);
setPromptsInQueue([]);
clearLiveToolRefs();
discardPendingStream();
const applyHydratedMessages = (
msgs: ChatMessage[],
sessionStatus: typeof session.status,
) => {
outstandingOptimisticUserIdsRef.current.clear();
rekeyedOptimisticIdByMessageIdRef.current = {};
lastCoreErrorBySessionRef.current = {};
const mergedMessages = mergeHydratedMessagesWithLive({
hydrated: msgs,
current: messagesRef.current,
sessionId: session.sessionId,
hydrationStartedAt,
});
// Hub attachment starts forwarding future events but does not replay the
// tool.started event for a command already in flight. Rebuild its routing
// key from canonical history before those future updates can arrive.
const liveToolState = deriveLiveToolState(mergedMessages);
liveToolMessageIdsRef.current = liveToolState.messageIds;
liveToolInputsRef.current = liveToolState.inputs;
setMessages(mergedMessages);
setRawTranscript("");
resetCounters();
setStatus(inferHydratedChatStatus(sessionStatus, msgs));
void refreshSessionDiffSummary(session.sessionId);
};
try {
const historyMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{ sessionId: session.sessionId, maxMessages: MAX_MESSAGES },
);
if (hydrationRequestIdRef.current !== requestId) return;
if (historyMessages.length > 0) {
void refreshPromptsInQueue(session.sessionId);
applyHydratedMessages(historyMessages, session.status);
setIsHydratingSession(false);
}
const attached = await desktopClient
.invoke<{
sessionId?: string;
status?: string;
provider?: string;
model?: string;
cwd?: string;
workspaceRoot?: string;
prompt?: string;
}>("chat_session_command", {
request: {
action: "attach",
sessionId: session.sessionId,
config: {
provider: session.provider,
model: session.model,
cwd: session.cwd,
workspaceRoot: session.workspaceRoot,
},
},
})
.catch((err) => {
if (historyMessages.length === 0) {
throw err;
}
setError(errorMessage(err));
return undefined;
});
if (hydrationRequestIdRef.current !== requestId) return;
setConfig((prev) => ({
...prev,
sessionId: session.sessionId,
provider: attached?.provider || session.provider || prev.provider,
model: attached?.model || session.model || prev.model,
workspaceRoot:
attached?.workspaceRoot ||
session.workspaceRoot ||
prev.workspaceRoot,
cwd:
attached?.cwd ||
attached?.workspaceRoot ||
session.workspaceRoot ||
session.cwd ||
prev.cwd,
}));
if (historyMessages.length > 0) {
setStatus(
inferHydratedChatStatus(
(attached?.status || session.status) as SessionHistoryStatus,
historyMessages,
),
);
return;
}
const synthesized: ChatMessage[] = [];
if (session.prompt?.trim()) {
synthesized.push({
id: makeId("history_user"),
sessionId: session.sessionId,
role: "user",
content: session.prompt.trim(),
createdAt: Date.now(),
});
}
void refreshPromptsInQueue(session.sessionId);
applyHydratedMessages(
synthesized,
(attached?.status || session.status) as SessionHistoryStatus,
);
} catch (err) {
if (hydrationRequestIdRef.current !== requestId) return;
const msg = errorMessage(err);
setError(msg);
setStatus("error");
setMessages([makeErrorChatMessage(session.sessionId, msg)]);
} finally {
if (hydrationRequestIdRef.current === requestId) {
setIsHydratingSession(false);
}
}
},
[
clearAbortFallbackTimeout,
clearLiveToolRefs,
discardPendingStream,
refreshPromptsInQueue,
refreshSessionDiffSummary,
resetStreamDedupe,
resetCounters,
],
);
const forkSession = useCallback(
async (options?: {
beforeRunCount?: number;
}): Promise<{
newSessionId: string;
forkedFromSessionId: string;
messages: ChatMessage[];
}> => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId) {
throw new Error("No active session to fork.");
}
if (BUSY_STATUSES.has(status)) {
throw new Error("Wait for the current turn to finish before forking.");
}
const payload = (await postSession({
action: "fork",
sessionId: activeSessionId,
config,
forkBeforeRunCount: options?.beforeRunCount,
})) as {
sessionId?: string;
forkedFromSessionId?: string;
};
const newSessionId =
typeof payload.sessionId === "string" ? payload.sessionId.trim() : "";
if (!newSessionId) {
throw new Error("Fork did not return a new session id.");
}
const forkedFromSessionId =
typeof payload.forkedFromSessionId === "string"
? payload.forkedFromSessionId
: activeSessionId;
const nextMessages = await desktopClient.invoke<ChatMessage[]>(
"read_session_messages",
{
sessionId: newSessionId,
maxMessages: MAX_MESSAGES,
},
);
return { newSessionId, forkedFromSessionId, messages: nextMessages };
},
[config, postSession, status],
);
const steerPromptInQueue = useCallback(
async (promptId: string) => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId || !promptId.trim()) {
return;
}
const payload = await postSession({
action: "steer_prompt",
sessionId: activeSessionId,
promptId,
});
setPromptsInQueue(
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
},
[postSession],
);
const updatePromptInQueue = useCallback(
async (promptId: string, prompt: string) => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId || !promptId.trim()) {
return;
}
const payload = await postSession({
action: "update_pending_prompt",
sessionId: activeSessionId,
promptId,
prompt,
});
setPromptsInQueue(
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
},
[postSession],
);
const removePromptInQueue = useCallback(
async (promptId: string): Promise<PromptInQueue | undefined> => {
const activeSessionId = activeSessionIdRef.current;
if (!activeSessionId || !promptId.trim()) {
return undefined;
}
const payload = await postSession({
action: "remove_pending_prompt",
sessionId: activeSessionId,
promptId,
});
setPromptsInQueue(
Array.isArray(payload.promptsInQueue) ? payload.promptsInQueue : [],
);
return payload.prompt;
},
[postSession],
);
const summary = useMemo(
() => ({
toolCalls,
tokensIn,
tokensOut,
cacheReadTokens,
totalCostUsd,
additions: diffSummary.additions,
deletions: diffSummary.deletions,
}),
[
diffSummary.additions,
diffSummary.deletions,
tokensIn,
tokensOut,
cacheReadTokens,
totalCostUsd,
toolCalls,
],
);
return {
sessionId,
status,
chatTransportState,
chatTransportError,
isHydratingSession,
activeAssistantMessageId,
config,
messages,
rawTranscript,
error,
summary,
fileDiffs,
promptsInQueue,
pendingToolApprovals,
pendingAskQuestions,
setConfig,
setWorkspacePath,
start,
hydrateSession,
sendPrompt,
steerPromptInQueue,
updatePromptInQueue,
removePromptInQueue,
approveToolApproval,
rejectToolApproval,
answerAskQuestion,
restoreCheckpoint,
forkSession,
proceedWhileRunning,
abort,
stop: abort,
reset,
};
}