Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import {
} from "./hooks/useDashboardChatTransport";
import { useI18n } from "../../components/useI18n";
import { buildChatTranscript } from "./transcriptUtils";
import { dbItemsToChatMessages, type DbHistoryItem } from "./sessionHistory";
import {
deleteChatRunTranscript,
saveChatRunTranscript,
} from "../Layout/chatRunPersistence";
import { ConfigHealthBanner } from "../../components/ConfigHealthBanner";
import type { Attachment } from "../../../../shared/attachments";
import type { ActiveTurn, ChatMessage, UsageState } from "./types";
Expand Down Expand Up @@ -265,6 +270,31 @@ function Chat({
activeTurnRef,
});

const hydratedInitialSessionRef = useRef(false);
useEffect(() => {
if (hydratedInitialSessionRef.current) return;
if (!initialSessionId || messages.length > 0) return;
hydratedInitialSessionRef.current = true;
let cancelled = false;
window.hermesAPI
.getSessionMessages(initialSessionId)
.then((items) => {
if (cancelled) return;
const restored = dbItemsToChatMessages(items as DbHistoryItem[]);
if (restored.length > 0) setMessages(restored);
})
.catch(() => {
/* best-effort restore; sending can still resume by session id */
});
return () => {
cancelled = true;
};
}, [initialSessionId, messages.length]);
Comment on lines +273 to +292

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Hydration ref set before async, then cancelled on messages.length change

hydratedInitialSessionRef.current = true is set synchronously before the getSessionMessages promise is awaited. If the user sends a message before the DB call resolves (changing messages.length from 0 to 1), the effect cleanup fires cancelled = true, abandoning the in-flight fetch. On the next invocation the ref guard causes an immediate early return, so session history is silently never restored. For a restored run with a session id but an empty local snapshot this leaves the chat visually history-less. Since the intent is a one-shot initialisation, consider tracking the promise itself or moving the guard set to after the fetch completes so a re-trigger with the same initialSessionId can retry.


useEffect(() => {
saveChatRunTranscript(runId, messages);
}, [runId, messages]);
Comment on lines +294 to +296

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Transcript saved on every streaming chunk

saveChatRunTranscript is called synchronously inside useEffect with messages as a dependency, meaning it fires on every single state update — including each streaming token during generation. This serializes and writes the entire message array to localStorage (potentially several KB) on every chunk, which during a long response could be hundreds of writes per second. Only the final or last-known state matters for crash recovery, so the intermediate-chunk saves are wasted work. Consider debouncing or writing only when streaming has paused (e.g. when isLoading turns false).


// No parent-driven reset effects: each run is its own <Chat key={runId}>
// instance. A new chat is a fresh mount, and switching sessions just flips
// which mounted instance is shown — local state (session id, context folder,
Expand Down Expand Up @@ -368,14 +398,17 @@ function Chat({
void window.hermesAPI.clearStagedAttachments(idToDelete);
}
setMessages([]);
deleteChatRunTranscript(runId);
setHermesSessionId(null);
setContextFolder(null);
activeTurnRef.current = null;
setUsage(null);
setToolProgress(null);
queueRef.current = [];
setQueuedMessages([]);
}, [isLoading, runId, hermesSessionId, setMessages]);
reportedTitleRef.current = false;
onTitleChange?.(runId, "");
}, [isLoading, runId, hermesSessionId, setMessages, onTitleChange]);

const localCommands = useLocalCommands({
profile,
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/src/screens/Chat/dashboardGatewayClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export class DashboardGatewayClient {
request<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs: number = this.requestTimeoutMs,
): Promise<T> {
const socket = this.socket;
if (!socket || socket.readyState !== WebSocket.OPEN) {
Expand All @@ -147,7 +148,7 @@ export class DashboardGatewayClient {
const timeout = window.setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Hermes dashboard request timed out: ${method}`));
}, this.requestTimeoutMs);
}, timeoutMs);
this.pending.set(id, {
resolve: (value: unknown) => resolve(value as T),
reject,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
} from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DashboardRpcEvent } from "../dashboardGatewayClient";
import { useDashboardChatTransport } from "./useDashboardChatTransport";
import {
dashboardSeedMessagesFromTranscript,
useDashboardChatTransport,
} from "./useDashboardChatTransport";
import type { ActiveTurn, ChatMessage } from "../types";

const dashboardMock = vi.hoisted(() => ({
Expand All @@ -32,7 +35,9 @@ vi.mock("../dashboardGatewayClient", () => ({
connected = true;
request = dashboardMock.request;

constructor(options: { onEvent?: (event: DashboardRpcEvent) => void } = {}) {
constructor(
options: { onEvent?: (event: DashboardRpcEvent) => void } = {},
) {
dashboardMock.onEvent = options.onEvent ?? null;
dashboardMock.instances.push(this);
}
Expand Down Expand Up @@ -342,4 +347,24 @@ describe("useDashboardChatTransport recovery", () => {
"prompt.submit",
);
});

it("keeps pending assistant text when recreating context from the visible transcript", () => {
expect(
dashboardSeedMessagesFromTranscript([
{ id: "user-1", role: "user", content: "original prompt" },
{
id: "agent-1",
role: "agent",
content: "partial work that streamed before reconnect",
pending: true,
},
]),
).toEqual([
{ role: "user", content: "original prompt" },
{
role: "assistant",
content: "partial work that streamed before reconnect",
},
]);
});
});
41 changes: 28 additions & 13 deletions src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ interface FileAttachResponse {
}

interface DashboardPromptClient {
request<T = unknown>(method: string, params?: unknown): Promise<T>;
request(
method: string,
params?: unknown,
timeoutMs?: number,
): Promise<unknown>;
}

export const DASHBOARD_SESSION_RESUME_TIMEOUT_MS = 120_000;

interface EnsureDashboardRuntimeSessionParams {
client: DashboardPromptClient;
contextFolder?: string | null;
Expand Down Expand Up @@ -167,9 +173,13 @@ export async function submitDashboardPromptWithRecovery(
throw err;
}

const resumed = await client.request<SessionResponse>("session.resume", {
session_id: params.storedSessionId,
});
const resumed = (await client.request(
"session.resume",
{
session_id: params.storedSessionId,
},
DASHBOARD_SESSION_RESUME_TIMEOUT_MS,
)) as SessionResponse;
const recoveredSessionId = resumed?.session_id;
if (!recoveredSessionId) {
throw err;
Expand All @@ -192,14 +202,15 @@ export async function ensureDashboardRuntimeSession(

if (stored) {
try {
const resumed = await params.client.request<SessionResponse>(
const resumed = (await params.client.request(
"session.resume",
{
session_id: stored,
cols,
...(params.profile ? { profile: params.profile } : {}),
},
);
DASHBOARD_SESSION_RESUME_TIMEOUT_MS,
)) as SessionResponse;
if (!resumed.session_id) {
throw new Error("session.resume returned no session_id");
}
Expand All @@ -218,12 +229,12 @@ export async function ensureDashboardRuntimeSession(
const seedMessages = dashboardSeedMessagesFromTranscript(params.messages, {
excludeUserId: params.excludeSeedUserId ?? null,
});
const created = await params.client.request<SessionResponse>("session.create", {
const created = (await params.client.request("session.create", {
cols,
...(seedMessages.length > 0 ? { messages: seedMessages } : {}),
...(params.contextFolder ? { cwd: params.contextFolder } : {}),
...(params.profile ? { profile: params.profile } : {}),
});
})) as SessionResponse;

return {
created: true,
Expand Down Expand Up @@ -393,14 +404,14 @@ export async function syncDashboardAttachmentsForSubmit(
if (!contentBase64) return { handled: false, refs: [] };

try {
const result = await client.request<ImageAttachBytesResponse>(
const result = (await client.request(
"image.attach_bytes",
{
session_id: sessionId,
content_base64: contentBase64,
filename: safeAttachmentFilename(image.name, index),
},
);
)) as ImageAttachBytesResponse;
if (!result?.attached) {
throw new Error(result?.message || `Could not attach ${image.name}`);
}
Expand Down Expand Up @@ -433,10 +444,10 @@ export async function syncDashboardAttachmentsForSubmit(
}

try {
const result = await client.request<FileAttachResponse>(
const result = (await client.request(
"file.attach",
params,
);
)) as FileAttachResponse;
if (!result?.attached || !result.ref_text) {
throw new Error(result?.message || `Could not attach ${name}`);
}
Expand Down Expand Up @@ -660,7 +671,11 @@ export function dashboardSeedMessagesFromTranscript(
for (const message of messages) {
if (!isBubbleMessage(message)) continue;
if (message.role === "user" && message.id === options.excludeUserId) continue;
if (message.localOnly || message.error || message.pending) continue;
// Pending is renderer-local, but if a connection/runtime session has to be
// recreated from the visible transcript, that partially streamed assistant
// text is still the only context the user saw. Error/local-only rows stay
// excluded; pending-but-readable assistant content is preserved.
if (message.localOnly || message.error) continue;
if (failedUserIds.has(message.id)) continue;
const content = normalizeMessageText(message.content);
if (!content) continue;
Expand Down
28 changes: 25 additions & 3 deletions src/renderer/src/screens/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ import {
findRunBySession,
loadingSessionIds as deriveLoadingSessionIds,
} from "./chatRuns";
import {
deleteChatRunTranscript,
persistChatRunsState,
restoreChatRunsState,
} from "./chatRunPersistence";
import { ActiveSessionsBar } from "./ActiveSessionsBar";
import Sessions from "../Sessions/Sessions";
import Agents from "../Agents/Agents";
Expand Down Expand Up @@ -103,12 +108,24 @@ function Layout({
}: LayoutProps = {}): React.JSX.Element {
const { t } = useI18n();
const [view, setView] = useState<View>("chat");
const restoredRunsRef = useRef<{
value: ReturnType<typeof restoreChatRunsState>;
} | null>(null);
if (restoredRunsRef.current === null) {
restoredRunsRef.current = { value: restoreChatRunsState() };
}
// Multiple conversations coexist (background sessions + multi-agent). Each is
// a ChatRun; all are mounted, only the active one is shown. `activeProfile`
// tracks the selected profile and always equals the active run's profile.
const [activeProfile, setActiveProfile] = useState("default");
const [runs, setRuns] = useState<ChatRun[]>(() => [mintRun("default")]);
const [activeRunId, setActiveRunId] = useState<string>(() => runs[0].runId);
const [activeProfile, setActiveProfile] = useState(
() => restoredRunsRef.current?.value?.activeProfile ?? "default",
);
const [runs, setRuns] = useState<ChatRun[]>(
() => restoredRunsRef.current?.value?.runs ?? [mintRun("default")],
);
const [activeRunId, setActiveRunId] = useState<string>(
() => restoredRunsRef.current?.value?.activeRunId ?? runs[0].runId,
);
// While a resume's history is loading, show its spinner immediately.
const [resumingSessionId, setResumingSessionId] = useState<string | null>(
null,
Expand All @@ -121,6 +138,10 @@ function Layout({
const currentSessionId =
runs.find((r) => r.runId === activeRunId)?.sessionId ?? null;

useEffect(() => {
persistChatRunsState(runs, activeRunId);
}, [runs, activeRunId]);

const loadingSessionIds = useMemo(
() => deriveLoadingSessionIds(runs),
[runs],
Expand Down Expand Up @@ -399,6 +420,7 @@ function Layout({
const handleCloseRun = useCallback(
(runId: string) => {
window.hermesAPI.abortChat(runId);
deleteChatRunTranscript(runId);
const idx = runs.findIndex((r) => r.runId === runId);
const remaining = runs.filter((r) => r.runId !== runId);
if (remaining.length === 0) {
Expand Down
72 changes: 72 additions & 0 deletions src/renderer/src/screens/Layout/chatRunPersistence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { ChatRun } from "./chatRuns";
import {
deleteChatRunTranscript,
loadChatRunTranscript,
persistChatRunsState,
restoreChatRunsState,
saveChatRunTranscript,
} from "./chatRunPersistence";
import type { ChatMessage } from "../Chat/types";

const run: ChatRun = {
runId: "run-saved",
profile: "research-agent",
sessionId: "session-saved",
loading: true,
title: "Long investigation",
};

const transcript: ChatMessage[] = [
{
id: "user-1",
role: "user",
content: "trace the context flow",
},
{
id: "agent-1",
role: "agent",
content: "partial analysis that streamed before reload",
pending: true,
},
];

describe("chat run persistence", () => {
beforeEach(() => {
window.localStorage.clear();
});

it("restores open runs with their visible transcript snapshots", () => {
saveChatRunTranscript(run.runId, transcript);
persistChatRunsState([run], run.runId);

const restored = restoreChatRunsState();

expect(restored?.activeRunId).toBe(run.runId);
expect(restored?.activeProfile).toBe(run.profile);
expect(restored?.runs).toHaveLength(1);
expect(restored?.runs[0]).toMatchObject({
runId: run.runId,
profile: run.profile,
sessionId: run.sessionId,
loading: false,
title: run.title,
});
expect(restored?.runs[0].seed?.[0]).toMatchObject(transcript[0]);
expect(restored?.runs[0].seed?.[1]).toMatchObject({
id: "agent-1",
role: "agent",
content: "partial analysis that streamed before reload",
pending: false,
});
});

it("deletes transcript snapshots for discarded runs", () => {
saveChatRunTranscript(run.runId, transcript);
expect(loadChatRunTranscript(run.runId)).toHaveLength(2);

deleteChatRunTranscript(run.runId);

expect(loadChatRunTranscript(run.runId)).toEqual([]);
});
});
Loading