From 202599c495ec61f9f13dbe181d1319040e47cb0b Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 13:04:38 -0600 Subject: [PATCH 1/2] fix(json-chat): suppress empty-state flash while entries are loading The wire snapshot ships sessions with stripped entries to keep initial-load latency bounded; the renderer lazy-fetches per session on first mount. Between mount and the entriesSeeded dispatch, the empty condition (entries=[] && !busy) was true, so the bright sparkle empty- state would render for ~one frame before messages appeared. Add an entriesHydrated boolean to JsonClaudeSession that the reducer flips true on entriesSeeded and stripJsonClaudeEntries resets to false. The renderer gates the empty-state on it, rendering null during the loading window (a spinner would itself be a flash). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/json-claude-status-deriver.test.ts | 1 + src/renderer/components/JsonModeChat.tsx | 40 +++++++++++++-------- src/shared/state/json-claude-todos.test.ts | 1 + src/shared/state/json-claude.test.ts | 35 +++++++++++++++++- src/shared/state/json-claude.ts | 25 +++++++++++-- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/main/json-claude-status-deriver.test.ts b/src/main/json-claude-status-deriver.test.ts index 082ac797..77cad722 100644 --- a/src/main/json-claude-status-deriver.test.ts +++ b/src/main/json-claude-status-deriver.test.ts @@ -24,6 +24,7 @@ function makeSession( exitCode: null, exitReason: null, entries: [], + entriesHydrated: false, busy: false, permissionMode: 'default', slashCommands: [], diff --git a/src/renderer/components/JsonModeChat.tsx b/src/renderer/components/JsonModeChat.tsx index 9895bbfc..9fbca587 100644 --- a/src/renderer/components/JsonModeChat.tsx +++ b/src/renderer/components/JsonModeChat.tsx @@ -1160,6 +1160,7 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo // delta — the visible cost is that streaming text lags the actual data // by a frame or two, which is invisible to the user. const entries = session?.entries ?? [] + const entriesHydrated = session?.entriesHydrated ?? false const deferredEntries = useDeferredValue(entries) const rows = useMemo(() => { // Sub-agent nesting pre-pass: split the flat entries array into a @@ -1644,23 +1645,32 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo style={{ overflowAnchor: 'none' }} > {entries.length === 0 && orphanApprovals.length === 0 && !busy ? ( -
-
-
-
- + // Empty-state is only safe to show once we've confirmed there's + // nothing to display. The wire snapshot ships entries stripped + // (entriesHydrated=false) and the lazy-fetch above seeds them + // shortly after mount — rendering the bright sparkle card in + // that window flashes loudly on tabs that actually have history. + // Render blank during the fetch; a spinner's appear-then- + // disappear would itself be a flash. + entriesHydrated ? ( +
+
+
+
+ +
+

+ What are we going to build today? +

+

+ Send a message to get started. +

-

- What are we going to build today? -

-

- Send a message to get started. -

-
+ ) : null ) : (
{groupedItems.map((g) => diff --git a/src/shared/state/json-claude-todos.test.ts b/src/shared/state/json-claude-todos.test.ts index 8dc697e6..46e0221c 100644 --- a/src/shared/state/json-claude-todos.test.ts +++ b/src/shared/state/json-claude-todos.test.ts @@ -12,6 +12,7 @@ function makeSession( exitCode: null, exitReason: null, entries, + entriesHydrated: true, busy: false, permissionMode: 'default', slashCommands: [], diff --git a/src/shared/state/json-claude.test.ts b/src/shared/state/json-claude.test.ts index 0433c0db..2270a549 100644 --- a/src/shared/state/json-claude.test.ts +++ b/src/shared/state/json-claude.test.ts @@ -23,6 +23,7 @@ describe('jsonClaudeReducer', () => { expect(next.sessions[SID].state).toBe('connecting') expect(next.sessions[SID].worktreePath).toBe(WT) expect(next.sessions[SID].entries).toEqual([]) + expect(next.sessions[SID].entriesHydrated).toBe(false) expect(next.sessions[SID].busy).toBe(false) }) @@ -108,6 +109,18 @@ describe('jsonClaudeReducer', () => { payload: { sessionId: SID, entries } }) expect(state.sessions[SID].entries).toEqual(entries) + expect(state.sessions[SID].entriesHydrated).toBe(true) + }) + + it('entriesSeeded flips entriesHydrated to true even with an empty array', () => { + let state = seedSession(initialJsonClaude) + expect(state.sessions[SID].entriesHydrated).toBe(false) + state = jsonClaudeReducer(state, { + type: 'jsonClaude/entriesSeeded', + payload: { sessionId: SID, entries: [] } + }) + expect(state.sessions[SID].entries).toEqual([]) + expect(state.sessions[SID].entriesHydrated).toBe(true) }) it('entriesSeeded is a no-op for unknown session', () => { @@ -1204,7 +1217,9 @@ describe('stripJsonClaudeEntries', () => { }) const stripped = stripJsonClaudeEntries(state) expect(stripped.sessions[SID].entries).toEqual([]) + expect(stripped.sessions[SID].entriesHydrated).toBe(false) expect(stripped.sessions['session-2'].entries).toEqual([]) + expect(stripped.sessions['session-2'].entriesHydrated).toBe(false) }) it('preserves non-entries fields on each session', () => { @@ -1242,9 +1257,27 @@ describe('stripJsonClaudeEntries', () => { expect(stripped.pendingApprovals).toBe(state.pendingApprovals) }) - it('returns the same session reference when entries are already empty', () => { + it('returns the same session reference when entries are already empty and not hydrated', () => { const state = seedSession(initialJsonClaude) + expect(state.sessions[SID].entriesHydrated).toBe(false) const stripped = stripJsonClaudeEntries(state) expect(stripped.sessions[SID]).toBe(state.sessions[SID]) }) + + it('resets entriesHydrated even when entries array is already empty', () => { + // Server-side post-hydration: entries was filled then drained back + // to []. entriesHydrated stays true. Stripping for the wire must + // still flip it so the renderer treats the new snapshot as not-yet- + // hydrated and re-fetches. + let state = seedSession(initialJsonClaude) + state = jsonClaudeReducer(state, { + type: 'jsonClaude/entriesSeeded', + payload: { sessionId: SID, entries: [] } + }) + expect(state.sessions[SID].entriesHydrated).toBe(true) + const stripped = stripJsonClaudeEntries(state) + expect(stripped.sessions[SID]).not.toBe(state.sessions[SID]) + expect(stripped.sessions[SID].entries).toEqual([]) + expect(stripped.sessions[SID].entriesHydrated).toBe(false) + }) }) diff --git a/src/shared/state/json-claude.ts b/src/shared/state/json-claude.ts index 6db7a541..84c043d4 100644 --- a/src/shared/state/json-claude.ts +++ b/src/shared/state/json-claude.ts @@ -132,6 +132,14 @@ export interface JsonClaudeSession { /** Buffered chat history for this session. Kept in the store so a * reloading renderer doesn't lose the scrollback. */ entries: JsonClaudeChatEntry[] + /** True once `entries` reflects the authoritative server-side history + * for this session. The wire snapshot ships sessions with stripped + * entries (see `stripJsonClaudeEntries`) and `entriesHydrated: false`, + * so the renderer can distinguish "haven't lazy-fetched entries yet" + * from "session is genuinely empty" — and suppress the empty-state + * flash during the fetch window. The reducer flips this true on + * `entriesSeeded`. Server-side it's always true. */ + entriesHydrated: boolean /** Last text of the most recent user submission; used by the renderer to * pair the echo against the user-card it just rendered optimistically. */ busy: boolean @@ -358,7 +366,15 @@ export const initialJsonClaude: JsonClaudeState = { export function stripJsonClaudeEntries(state: JsonClaudeState): JsonClaudeState { const sessions: Record = {} for (const [id, session] of Object.entries(state.sessions)) { - sessions[id] = session.entries.length === 0 ? session : { ...session, entries: [] } + // Server-side sessions are always hydrated; renderer-side they may + // not be. Either case where stripping would actually change the + // session shape (non-empty entries OR a true hydrated flag) requires + // a new object — otherwise return the existing reference so + // downstream identity checks don't trip. + const needsStrip = session.entries.length > 0 || session.entriesHydrated + sessions[id] = needsStrip + ? { ...session, entries: [], entriesHydrated: false } + : session } return { ...state, sessions } } @@ -446,6 +462,7 @@ export function jsonClaudeReducer( exitCode: null, exitReason: null, entries: existing?.entries ?? [], + entriesHydrated: existing?.entriesHydrated ?? false, busy: false, permissionMode: existing?.permissionMode ?? @@ -497,7 +514,11 @@ export function jsonClaudeReducer( ...state, sessions: { ...state.sessions, - [session.sessionId]: { ...session, entries: event.payload.entries } + [session.sessionId]: { + ...session, + entries: event.payload.entries, + entriesHydrated: true + } } } } From 000e668f3b0471afd3327527cc57756543a553a7 Mon Sep 17 00:00:00 2001 From: Mike Lyons Date: Mon, 25 May 2026 13:12:13 -0600 Subject: [PATCH 2/2] fix(json-chat): seed entries on empty sessions too so empty-state shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit gated the empty-state on `entriesHydrated`, but the `jsonClaude:getEntries` handler only dispatched `entriesSeeded` when the session had non-empty entries — so brand-new (truly empty) sessions never flipped hydrated to true, and the empty-state card never appeared. Always dispatch when the session exists. The reducer handles the empty payload fine; the cost is one extra dispatch per new-tab open. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index a9e598a9..63b0f8dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2590,7 +2590,11 @@ function registerIpcHandlers(): void { if (!sessionId) return [] const session = store.getSnapshot().state.jsonClaude.sessions[sessionId] const entries = session?.entries ?? [] - if (session && entries.length > 0) { + // Always dispatch when the session exists — even with empty entries, + // so the slice flips `entriesHydrated: true`. The renderer uses that + // flag to distinguish "haven't fetched yet" (render blank) from + // "truly empty session" (render the empty-state card). + if (session) { store.dispatch({ type: 'jsonClaude/entriesSeeded', payload: { sessionId, entries }