-
-
-
+ // 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
+ }
}
}
}