Skip to content

fix: restore desktop chat context after reload#672

Open
Reza2kn wants to merge 2 commits into
fathah:mainfrom
Reza2kn:fix/persist-chat-context
Open

fix: restore desktop chat context after reload#672
Reza2kn wants to merge 2 commits into
fathah:mainfrom
Reza2kn:fix/persist-chat-context

Conversation

@Reza2kn

@Reza2kn Reza2kn commented Jun 15, 2026

Copy link
Copy Markdown

Summary

  • Persist active desktop chat run metadata (active run, profile, session id, title) and restore it on app reload instead of always minting a blank default chat.
  • Persist renderer-visible transcript snapshots per run, restore them on launch, and hydrate from the Hermes session DB when a restored run has a session id but no local snapshot.
  • Preserve pending-but-visible assistant text when recreating dashboard runtime context from the visible transcript, instead of silently dropping the only output the user saw.

Test Plan

  • npm test -- --run src/renderer/src/screens/Layout/chatRunPersistence.test.ts src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.test.tsx
  • npm test
  • npm run typecheck
  • npm run build

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds localStorage-backed persistence of active desktop chat runs (run metadata, profile, session id, title) and per-run transcript snapshots, restoring them on app reload instead of always starting from a blank default chat. It also threads a configurable per-request timeout through DashboardGatewayClient.request (used to give session.resume a 2-minute window) and preserves partially-streamed assistant text when a dashboard runtime session has to be re-created from the visible transcript.

  • chatRunPersistence.ts (new): saves/restores up to 12 runs and their transcripts; sanitization covers all ChatMessage variants and clears the pending flag on restored bubble messages so recovered assistant text is eligible as seed context.
  • Chat.tsx: adds a one-shot DB-hydration effect for restored runs that have a session id but no local transcript snapshot, autosaves the transcript on every message change, and resets the title on clear-chat.
  • useDashboardChatTransport.ts: removes pending from the dashboardSeedMessagesFromTranscript exclusion list (safe — pending is only ever set on agent/assistant messages) and plumbs the new timeout parameter through all session.resume call sites.

Confidence Score: 4/5

Safe to merge; the new persistence layer is well-guarded with try/catch and graceful fallbacks, and no new blocking issues were introduced.

The three issues noted in the previous review round (hydration guard set before the async await, transcript saves on every streaming chunk, and orphaned transcript keys beyond the 12-run cap) are still present and unaddressed, which is the main reason for caution.

src/renderer/src/screens/Chat/Chat.tsx (hydration guard and streaming-chunk save rate) and src/renderer/src/screens/Layout/chatRunPersistence.ts (orphaned transcript cleanup, tool-call running-status clamping)

Important Files Changed

Filename Overview
src/renderer/src/screens/Layout/chatRunPersistence.ts New file implementing localStorage persistence for run metadata and transcripts. Sanitization is careful and handles all ChatMessage variants. Minor concern: ToolCallMessage records with status "running" are preserved verbatim, showing as stale in-progress indicators after reload.
src/renderer/src/screens/Chat/Chat.tsx Adds DB hydration effect for restored runs with no local snapshot, transcript autosave on message changes, and clear-chat title reset. The hydration guard is set before the async await (previously flagged); transcript saves fire on every streaming chunk (previously flagged).
src/renderer/src/screens/Layout/Layout.tsx Restores run state via chatRunPersistence on mount and persists it on every runs/activeRunId change. The lazy-init pattern using useRef during render is unconventional but functionally correct. Transcript cleanup for runs dropped by the MAX_RESTORED_RUNS cap is missing (previously flagged).
src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts Removes pending-message exclusion from dashboardSeedMessagesFromTranscript (pending is only ever set on agent messages, so user messages are unaffected) and adds a configurable per-request timeout used for session.resume calls. DashboardPromptClient interface loses generic type parameter in exchange for the timeout param — all call sites now cast the result explicitly.
src/renderer/src/screens/Chat/dashboardGatewayClient.ts Adds optional per-request timeoutMs override to DashboardGatewayClient.request, defaulting to the existing instance-level requestTimeoutMs. Change is minimal, additive, and well-tested.
src/renderer/src/screens/Layout/chatRunPersistence.test.ts New test file covering restore-round-trip and transcript deletion. Tests confirm pending flag is cleared on restore and snapshot is properly cleaned up on delete.
tests/dashboard-chat-transport.test.ts Extended to capture timeoutMs in call records and assert that DASHBOARD_SESSION_RESUME_TIMEOUT_MS is passed for all session.resume calls. Coverage looks correct.
tests/dashboard-gateway-client.test.ts New test verifies that a per-request timeout override of 120 s is honored and the default 30 s timeout does not fire early.

Sequence Diagram

sequenceDiagram
    participant App as App Reload
    participant Layout
    participant Persist as chatRunPersistence
    participant LS as localStorage
    participant Chat
    participant DB as Hermes Session DB

    App->>Layout: mount
    Layout->>Persist: restoreChatRunsState()
    Persist->>LS: getItem("hermes.chat.runs.v1")
    LS-->>Persist: persisted run metadata
    loop each run
        Persist->>LS: "getItem("hermes.chat.transcript.<runId>")"
        LS-->>Persist: transcript snapshot (or empty)
    end
    Persist-->>Layout: "{ activeRunId, activeProfile, runs[] with seed }"

    Layout->>Chat: "mount(runId, initialMessages=seed, initialSessionId)"
    alt seed non-empty
        Chat->>Chat: "messages = seed (skip DB hydration)"
    else seed empty and sessionId known
        Chat->>DB: getSessionMessages(initialSessionId)
        DB-->>Chat: history items
        Chat->>Chat: setMessages(dbItemsToChatMessages(items))
    end

    Chat->>Persist: saveChatRunTranscript(runId, messages)
    Persist->>LS: "setItem("hermes.chat.transcript.<runId>", JSON)"

    Note over Layout,LS: On any runs/activeRunId change
    Layout->>Persist: persistChatRunsState(runs, activeRunId)
    Persist->>LS: setItem("hermes.chat.runs.v1", JSON)
Loading

Reviews (2): Last reviewed commit: "fix: allow slow dashboard session resume" | Re-trigger Greptile

Comment on lines +273 to +292
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]);

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.

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

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).

Comment on lines +207 to +221
JSON.stringify(messages),
);
} catch {
// localStorage can be unavailable or full. The canonical Hermes session DB
// still remains the primary source for completed turns; this snapshot is a
// best-effort crash/reload safety net for renderer-visible state.
}
}

export function deleteChatRunTranscript(runId: string): void {
if (!runId) return;
try {
window.localStorage.removeItem(transcriptStorageKey(runId));
} catch {
/* ignore */

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 keys orphaned when run list exceeds MAX_RESTORED_RUNS

persistChatRunsState saves only the last 12 runs via runs.slice(-MAX_RESTORED_RUNS) but never removes the localStorage transcript entries for the runs that were dropped from the slice. When a user accumulates more than 12 open runs and then reloads, restoreChatRunsState only sees the kept 12 and never calls deleteChatRunTranscript for the others, so their keys (hermes.chat.transcript.<id>) accumulate in localStorage indefinitely. Consider iterating over runs.slice(0, -MAX_RESTORED_RUNS) and calling deleteChatRunTranscript for each before persisting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant