1752 lines
47 KiB
TypeScript
1752 lines
47 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { serializeAttachments } from "@/hooks/chat-session/attachments";
|
|
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
|
|
import {
|
|
buildToolPayloadString,
|
|
extractAssistantTurnDataFromRpcMessages,
|
|
inferHydratedChatStatus,
|
|
makeId,
|
|
normalizeRuntimeConfig,
|
|
resolveCredentialError,
|
|
} from "@/hooks/chat-session/helpers";
|
|
import type {
|
|
AgentChunkEvent,
|
|
AskQuestionRequestItem,
|
|
ChatApiResult,
|
|
ChatSessionHookEvent,
|
|
ChatTransportState,
|
|
CoreLogChunk,
|
|
ProcessContext,
|
|
PromptInQueue,
|
|
ReasoningDeltaEvent,
|
|
ToolApprovalRequestItem,
|
|
ToolCallEndEvent,
|
|
ToolCallStartEvent,
|
|
} from "@/hooks/chat-session/types";
|
|
import {
|
|
type ChatMessage,
|
|
type ChatSessionConfig,
|
|
ChatSessionConfigSchema,
|
|
type ChatSessionStatus,
|
|
} from "@/lib/chat-schema";
|
|
import { desktopClient } from "@/lib/desktop-client";
|
|
import {
|
|
buildSessionDiffState,
|
|
type SessionHookEvent,
|
|
EMPTY_DIFF_SUMMARY,
|
|
type SessionDiffSummary,
|
|
type SessionFileDiff,
|
|
} from "@/lib/session-diff";
|
|
import type {
|
|
SessionHistoryItem,
|
|
SessionHistoryStatus,
|
|
} from "@/lib/session-history";
|
|
|
|
export { DEFAULT_CHAT_CONFIG } from "@/hooks/chat-session/constants";
|
|
|
|
const MAX_MESSAGES = 800;
|
|
|
|
const RELEVANT_STREAMS = new Set([
|
|
"chat_text",
|
|
"chat_reasoning",
|
|
"chat_queued_prompt_start",
|
|
"chat_tool_call_start",
|
|
"chat_tool_call_end",
|
|
"chat_core_log",
|
|
"chat_usage",
|
|
"chat_done",
|
|
]);
|
|
|
|
const BUSY_STATUSES = new Set<ChatSessionStatus>([
|
|
"starting",
|
|
"running",
|
|
"stopping",
|
|
]);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 {
|
|
const ts = payload.ts || Date.now();
|
|
const index = payload.index ?? 0;
|
|
return ts * 1000 + index;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
};
|
|
|
|
function dispatchCoreLog(chunk: string): void {
|
|
let parsed: CoreLogChunk | undefined;
|
|
try {
|
|
parsed = JSON.parse(chunk) as CoreLogChunk;
|
|
} catch {
|
|
console.info("[core]", chunk);
|
|
return;
|
|
}
|
|
const level = parsed.level?.trim().toLowerCase() || "info";
|
|
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 [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[]>([]);
|
|
const liveToolMessageIdsRef = 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 [chatTransportState, setChatTransportState] =
|
|
useState<ChatTransportState>(desktopClient.getTransportState());
|
|
const [chatTransportError, setChatTransportError] = useState<string | null>(
|
|
desktopClient.getTransportError(),
|
|
);
|
|
// ---- Ref syncs ----
|
|
|
|
useEffect(() => {
|
|
activeSessionIdRef.current = sessionId;
|
|
}, [sessionId]);
|
|
useEffect(() => {
|
|
activeAssistantMessageIdRef.current = activeAssistantMessageId;
|
|
}, [activeAssistantMessageId]);
|
|
useEffect(() => {
|
|
messagesRef.current = messages;
|
|
}, [messages]);
|
|
|
|
// ---- Shared state reset helpers ----
|
|
|
|
const clearLiveToolRefs = useCallback(() => {
|
|
liveToolMessageIdsRef.current = {};
|
|
liveToolInputsRef.current = {};
|
|
}, []);
|
|
|
|
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(() => {
|
|
setToolCalls(0);
|
|
setTokensIn(0);
|
|
setTokensOut(0);
|
|
setFileDiffs([]);
|
|
setDiffSummary(EMPTY_DIFF_SUMMARY);
|
|
}, []);
|
|
|
|
const setErrorState = useCallback(
|
|
(msg: string, sid: string | null = null) => {
|
|
setError(msg);
|
|
setStatus("error");
|
|
setMessages((prev) =>
|
|
sliceMessages([...prev, makeErrorChatMessage(sid, msg)]),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ---- Data fetching ----
|
|
|
|
const postSession = useCallback(async (body: Record<string, unknown>) => {
|
|
return await desktopClient.invoke<{
|
|
sessionId?: string;
|
|
result?: ChatApiResult;
|
|
ok?: boolean;
|
|
queued?: boolean;
|
|
promptsInQueue?: PromptInQueue[];
|
|
prompt?: PromptInQueue;
|
|
updated?: boolean;
|
|
removed?: boolean;
|
|
}>("chat_session_command", { request: body });
|
|
}, []);
|
|
|
|
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 refreshSessionDiffSummary = useCallback(
|
|
async (targetSessionId: string) => {
|
|
try {
|
|
const events = await desktopClient.invoke<ChatSessionHookEvent[]>(
|
|
"read_session_hooks",
|
|
{ sessionId: targetSessionId, limit: MAX_MESSAGES },
|
|
);
|
|
const diffState = buildSessionDiffState(events);
|
|
setFileDiffs(diffState.fileDiffs);
|
|
setDiffSummary(diffState.summary);
|
|
setToolCalls(
|
|
events.filter(
|
|
(e) =>
|
|
e.hookEventName === "tool_call" || e.hookName === "tool_call",
|
|
).length,
|
|
);
|
|
setTokensIn(events.reduce((sum, e) => sum + (e.inputTokens ?? 0), 0));
|
|
setTokensOut(events.reduce((sum, e) => sum + (e.outputTokens ?? 0), 0));
|
|
} catch {
|
|
// Ignore in non-Tauri mode.
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ---- 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,
|
|
};
|
|
}),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// ---- Process context ----
|
|
|
|
const applyProcessContext = useCallback(async () => {
|
|
try {
|
|
const ctx = await desktopClient.invoke<ProcessContext>(
|
|
"get_process_context",
|
|
);
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
workspaceRoot: ctx.workspaceRoot || ctx.cwd,
|
|
cwd: ctx.workspaceRoot || ctx.cwd,
|
|
}));
|
|
} 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);
|
|
if (diffState.fileDiffs.length === 0) {
|
|
return;
|
|
}
|
|
setFileDiffs(diffState.fileDiffs);
|
|
setDiffSummary(diffState.summary);
|
|
}, [sessionId, messages, fileDiffs.length]);
|
|
|
|
useEffect(() => {
|
|
const activeSessionId = sessionId;
|
|
if (!activeSessionId) {
|
|
setPendingToolApprovals([]);
|
|
return;
|
|
}
|
|
|
|
void desktopClient
|
|
.invoke<ToolApprovalRequestItem[]>("poll_tool_approvals", {
|
|
sessionId: activeSessionId,
|
|
limit: 20,
|
|
})
|
|
.then((pending) => setPendingToolApprovals(pending))
|
|
.catch(() => {});
|
|
|
|
return 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 : []);
|
|
});
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
return desktopClient.subscribe("ask_question_requested", (payload) => {
|
|
if (!payload || typeof payload !== "object") return;
|
|
const item = payload as AskQuestionRequestItem;
|
|
if (!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;
|
|
}
|
|
if (abortedRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (payload.stream === "chat_queued_prompt_start") {
|
|
let parsed: { prompt?: string; attachmentCount?: number } = {};
|
|
try {
|
|
parsed = JSON.parse(payload.chunk) as {
|
|
prompt?: string;
|
|
attachmentCount?: number;
|
|
};
|
|
} catch {
|
|
parsed = { prompt: payload.chunk };
|
|
}
|
|
const prompt = parsed.prompt?.trim() ?? "";
|
|
const attachmentCount =
|
|
typeof parsed.attachmentCount === "number"
|
|
? parsed.attachmentCount
|
|
: 0;
|
|
const userLabel =
|
|
attachmentCount > 0
|
|
? `${prompt}${prompt.length > 0 ? "\n\n" : ""}[attached ${attachmentCount} file${attachmentCount === 1 ? "" : "s"}]`
|
|
: prompt;
|
|
activeAssistantMessageIdRef.current = null;
|
|
setActiveAssistantMessageId(null);
|
|
clearLiveToolRefs();
|
|
setStatus("running");
|
|
if (userLabel) {
|
|
setMessages((prev) => {
|
|
const lastVisible = [...prev]
|
|
.reverse()
|
|
.find(
|
|
(message) =>
|
|
message.sessionId === listeningSessionId &&
|
|
message.role !== "status",
|
|
);
|
|
if (
|
|
lastVisible?.role === "user" &&
|
|
lastVisible.content === userLabel
|
|
) {
|
|
return prev;
|
|
}
|
|
return sliceMessages([
|
|
...prev,
|
|
{
|
|
id: makeId("user"),
|
|
sessionId: listeningSessionId,
|
|
role: "user",
|
|
content: userLabel,
|
|
createdAt: chunkCreatedAt(payload),
|
|
},
|
|
]);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Text stream ---
|
|
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);
|
|
}
|
|
appendMessageContent(assistantId, payload.chunk);
|
|
setRawTranscript((prev) => `${prev}${payload.chunk}`);
|
|
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 };
|
|
}
|
|
appendMessageReasoning(
|
|
assistantId,
|
|
parsed.text ?? "",
|
|
parsed.redacted === true,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// --- Core log ---
|
|
if (payload.stream === "chat_core_log") {
|
|
dispatchCoreLog(payload.chunk);
|
|
return;
|
|
}
|
|
|
|
if (payload.stream === "chat_usage") {
|
|
// Usage is still finalized from the send() response to avoid
|
|
// double-counting when both stream and response include it.
|
|
return;
|
|
}
|
|
|
|
if (payload.stream === "chat_done") {
|
|
clearLiveToolRefs();
|
|
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, 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, 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,
|
|
appendMessageContent,
|
|
appendMessageReasoning,
|
|
clearLiveToolRefs,
|
|
shouldApplyStreamChunk,
|
|
],
|
|
);
|
|
|
|
// ---- 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;
|
|
}
|
|
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();
|
|
setStatus((record.reason?.trim() || "idle") as ChatSessionStatus);
|
|
},
|
|
);
|
|
return () => {
|
|
unsubscribeStatus();
|
|
unsubscribeEnded();
|
|
};
|
|
}, [clearLiveToolRefs]);
|
|
|
|
// ---- Shared: start a new session via RPC ----
|
|
|
|
const startSession = useCallback(
|
|
async (validatedConfig: ChatSessionConfig): Promise<string> => {
|
|
const payload = await postSession({
|
|
action: "start",
|
|
config: validatedConfig,
|
|
});
|
|
const id = payload.sessionId;
|
|
if (!id) throw new Error("Missing session id 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.
|
|
setStatus("idle");
|
|
setConfig(validatedConfig);
|
|
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();
|
|
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,
|
|
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();
|
|
let activeSessionId = sessionId;
|
|
|
|
const validation = validateConfig(config);
|
|
if (!validation.parsed) {
|
|
setErrorState(validation.error, activeSessionId);
|
|
return;
|
|
}
|
|
const parsed = validation.parsed;
|
|
|
|
if (activeSessionId && hydratedHistorySessionId === activeSessionId) {
|
|
try {
|
|
activeSessionId = await startSession({
|
|
...parsed,
|
|
sessionId: activeSessionId,
|
|
});
|
|
} catch (err) {
|
|
setErrorState(errorMessage(err), activeSessionId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!activeSessionId) {
|
|
try {
|
|
activeSessionId = await startSession(parsed);
|
|
} catch (err) {
|
|
setErrorState(errorMessage(err));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const now = Date.now();
|
|
const shouldQueue = Boolean(activeSessionId) && BUSY_STATUSES.has(status);
|
|
const serializedAttachments = await serializeAttachments(attachedFiles);
|
|
const hasAttachments =
|
|
serializedAttachments.userImages.length > 0 ||
|
|
serializedAttachments.userFiles.length > 0;
|
|
|
|
const userLabel = hasAttachments
|
|
? `${trimmed}${trimmed.length > 0 ? "\n\n" : ""}[attached ${attachedFiles.length} file${attachedFiles.length === 1 ? "" : "s"}]`
|
|
: trimmed;
|
|
const optimisticQueuedPromptId = shouldQueue
|
|
? makeId("queued_prompt")
|
|
: null;
|
|
|
|
if (!shouldQueue) {
|
|
addMessage({
|
|
id: makeId("user"),
|
|
sessionId: activeSessionId,
|
|
role: "user",
|
|
content: userLabel,
|
|
createdAt: now,
|
|
});
|
|
activeSessionIdRef.current = activeSessionId;
|
|
activeAssistantMessageIdRef.current = null;
|
|
setActiveAssistantMessageId(null);
|
|
clearLiveToolRefs();
|
|
setStatus("starting");
|
|
} else if (optimisticQueuedPromptId) {
|
|
setPromptsInQueue((prev) => [
|
|
...prev,
|
|
{
|
|
id: optimisticQueuedPromptId,
|
|
prompt: userLabel,
|
|
steer: false,
|
|
},
|
|
]);
|
|
}
|
|
try {
|
|
const payload = await postSession({
|
|
action: "send",
|
|
sessionId: activeSessionId,
|
|
prompt: trimmed,
|
|
delivery: shouldQueue ? "queue" : undefined,
|
|
config: parsed,
|
|
attachments: hasAttachments ? serializedAttachments : undefined,
|
|
});
|
|
if (payload.ok && payload.queued) {
|
|
applyPromptsInQueue(payload.promptsInQueue);
|
|
setStatus("running");
|
|
return;
|
|
}
|
|
|
|
const result = payload.result as ChatApiResult | undefined;
|
|
applyPromptsInQueue(payload.promptsInQueue);
|
|
if (abortedRef.current) {
|
|
setStatus("cancelled");
|
|
return;
|
|
}
|
|
const assistantText = (result?.text ?? "").trim();
|
|
const fallbackAssistantTurn = extractAssistantTurnDataFromRpcMessages(
|
|
result?.messages,
|
|
);
|
|
const resolvedAssistantText =
|
|
assistantText || fallbackAssistantTurn.text;
|
|
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,
|
|
},
|
|
]);
|
|
});
|
|
} else {
|
|
// 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) {
|
|
setMessages(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 },
|
|
);
|
|
if (historyMessages.length > 0) {
|
|
setMessages(historyMessages);
|
|
}
|
|
} catch {
|
|
// Keep optimistic state if canonical hydration fails.
|
|
}
|
|
|
|
// Token / cost bookkeeping
|
|
const inputTokens = result?.usage?.inputTokens ?? result?.inputTokens;
|
|
if (typeof inputTokens === "number") {
|
|
setTokensIn((prev) => prev + inputTokens);
|
|
}
|
|
const outputTokens =
|
|
result?.usage?.outputTokens ?? result?.outputTokens;
|
|
if (typeof outputTokens === "number") {
|
|
setTokensOut((prev) => prev + outputTokens);
|
|
}
|
|
const totalCost =
|
|
typeof result?.usage?.totalCost === "number"
|
|
? result.usage.totalCost
|
|
: undefined;
|
|
const assistantMessageId = activeAssistantMessageIdRef.current;
|
|
if (
|
|
assistantMessageId &&
|
|
(typeof inputTokens === "number" ||
|
|
typeof outputTokens === "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,
|
|
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 (abortedRef.current) {
|
|
setStatus("cancelled");
|
|
} else if (result?.finishReason === "error") {
|
|
if (!resolvedAssistantText) {
|
|
const toolError = Array.isArray(result?.toolCalls)
|
|
? result.toolCalls.find((c) => c.error)?.error
|
|
: undefined;
|
|
addMessage(
|
|
makeErrorChatMessage(
|
|
activeSessionId,
|
|
toolError?.trim() ||
|
|
"Runtime turn failed before an assistant response was produced.",
|
|
),
|
|
);
|
|
}
|
|
setStatus("failed");
|
|
} else if (result?.finishReason === "aborted") {
|
|
setStatus("cancelled");
|
|
} else if (hasQueuedFollowUps) {
|
|
setStatus("running");
|
|
} else {
|
|
setStatus("completed");
|
|
}
|
|
void refreshSessionDiffSummary(activeSessionId);
|
|
} catch (err) {
|
|
if (abortedRef.current) {
|
|
setStatus("cancelled");
|
|
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();
|
|
}
|
|
}
|
|
},
|
|
[
|
|
addMessage,
|
|
applyPromptsInQueue,
|
|
clearAbortFallbackTimeout,
|
|
clearLiveToolRefs,
|
|
config,
|
|
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;
|
|
messages?: ChatMessage[];
|
|
};
|
|
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 = Array.isArray(payload.messages)
|
|
? (payload.messages as ChatMessage[])
|
|
: 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;
|
|
abortedRef.current = true;
|
|
setStatus("stopping");
|
|
clearAbortFallbackTimeout();
|
|
abortFallbackTimeoutRef.current = setTimeout(() => {
|
|
if (abortedRef.current) {
|
|
setStatus("cancelled");
|
|
}
|
|
abortFallbackTimeoutRef.current = null;
|
|
}, 2000);
|
|
try {
|
|
const response = await postSession({ action: "abort", sessionId });
|
|
if (!response.ok) {
|
|
abortedRef.current = false;
|
|
clearAbortFallbackTimeout();
|
|
setStatus("running");
|
|
}
|
|
} catch {
|
|
abortedRef.current = false;
|
|
clearAbortFallbackTimeout();
|
|
setStatus("running");
|
|
}
|
|
}, [clearAbortFallbackTimeout, postSession, sessionId]);
|
|
|
|
const reset = useCallback(async () => {
|
|
const activeSessionId = sessionId;
|
|
setSessionId(null);
|
|
setStatus("idle");
|
|
setIsHydratingSession(false);
|
|
abortedRef.current = false;
|
|
clearAbortFallbackTimeout();
|
|
setMessages([]);
|
|
setRawTranscript("");
|
|
setError(null);
|
|
resetCounters();
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
sessionId: undefined,
|
|
}));
|
|
activeSessionIdRef.current = null;
|
|
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,
|
|
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);
|
|
setHydratedHistorySessionId(session.sessionId);
|
|
setPendingToolApprovals([]);
|
|
setPendingAskQuestions([]);
|
|
setPromptsInQueue([]);
|
|
clearLiveToolRefs();
|
|
|
|
const applyHydratedMessages = (
|
|
msgs: ChatMessage[],
|
|
sessionStatus: typeof session.status,
|
|
) => {
|
|
const mergedMessages = mergeHydratedMessagesWithLive({
|
|
hydrated: msgs,
|
|
current: messagesRef.current,
|
|
sessionId: session.sessionId,
|
|
hydrationStartedAt,
|
|
});
|
|
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,
|
|
refreshPromptsInQueue,
|
|
refreshSessionDiffSummary,
|
|
resetStreamDedupe,
|
|
resetCounters,
|
|
],
|
|
);
|
|
|
|
const forkSession = useCallback(async (): 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,
|
|
})) as {
|
|
sessionId?: string;
|
|
forkedFromSessionId?: string;
|
|
messages?: ChatMessage[];
|
|
};
|
|
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 = Array.isArray(payload.messages)
|
|
? (payload.messages as ChatMessage[])
|
|
: 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,
|
|
additions: diffSummary.additions,
|
|
deletions: diffSummary.deletions,
|
|
}),
|
|
[
|
|
diffSummary.additions,
|
|
diffSummary.deletions,
|
|
tokensIn,
|
|
tokensOut,
|
|
toolCalls,
|
|
],
|
|
);
|
|
|
|
return {
|
|
sessionId,
|
|
status,
|
|
chatTransportState,
|
|
chatTransportError,
|
|
isHydratingSession,
|
|
activeAssistantMessageId,
|
|
config,
|
|
messages,
|
|
rawTranscript,
|
|
error,
|
|
summary,
|
|
fileDiffs,
|
|
promptsInQueue,
|
|
pendingToolApprovals,
|
|
pendingAskQuestions,
|
|
setConfig,
|
|
start,
|
|
hydrateSession,
|
|
sendPrompt,
|
|
steerPromptInQueue,
|
|
updatePromptInQueue,
|
|
removePromptInQueue,
|
|
approveToolApproval,
|
|
rejectToolApproval,
|
|
answerAskQuestion,
|
|
restoreCheckpoint,
|
|
forkSession,
|
|
abort,
|
|
stop: abort,
|
|
reset,
|
|
};
|
|
}
|