Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions src/main/json-claude-status-deriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function makeSession(
exitCode: null,
exitReason: null,
entries: [],
entriesHydrated: false,
busy: false,
permissionMode: 'default',
slashCommands: [],
Expand Down
40 changes: 25 additions & 15 deletions src/renderer/components/JsonModeChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1644,23 +1645,32 @@ export function JsonModeChat({ sessionId, worktreePath, mode = 'awake' }: JsonMo
style={{ overflowAnchor: 'none' }}
>
{entries.length === 0 && orphanApprovals.length === 0 && !busy ? (
<div className="min-h-full flex flex-col items-center justify-center text-center px-4 select-none">
<div className="relative mb-6">
<div
className="absolute inset-0 rounded-full blur-2xl opacity-30 brand-gradient-bg"
aria-hidden
/>
<div className="relative w-16 h-16 rounded-full brand-gradient-bg flex items-center justify-center shadow-lg">
<Sparkles size={26} className="text-white" />
// 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 ? (
<div className="min-h-full flex flex-col items-center justify-center text-center px-4 select-none">
<div className="relative mb-6">
<div
className="absolute inset-0 rounded-full blur-2xl opacity-30 brand-gradient-bg"
aria-hidden
/>
<div className="relative w-16 h-16 rounded-full brand-gradient-bg flex items-center justify-center shadow-lg">
<Sparkles size={26} className="text-white" />
</div>
</div>
<h2 className="text-xl font-semibold brand-gradient-text">
What are we going to build today?
</h2>
<p className="mt-2 text-xs text-muted">
Send a message to get started.
</p>
</div>
<h2 className="text-xl font-semibold brand-gradient-text">
What are we going to build today?
</h2>
<p className="mt-2 text-xs text-muted">
Send a message to get started.
</p>
</div>
) : null
) : (
<div className="px-4 py-3 space-y-3">
{groupedItems.map((g) =>
Expand Down
1 change: 1 addition & 0 deletions src/shared/state/json-claude-todos.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makeSession(
exitCode: null,
exitReason: null,
entries,
entriesHydrated: true,
busy: false,
permissionMode: 'default',
slashCommands: [],
Expand Down
35 changes: 34 additions & 1 deletion src/shared/state/json-claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})
})
25 changes: 23 additions & 2 deletions src/shared/state/json-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -358,7 +366,15 @@ export const initialJsonClaude: JsonClaudeState = {
export function stripJsonClaudeEntries(state: JsonClaudeState): JsonClaudeState {
const sessions: Record<string, JsonClaudeSession> = {}
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 }
}
Expand Down Expand Up @@ -446,6 +462,7 @@ export function jsonClaudeReducer(
exitCode: null,
exitReason: null,
entries: existing?.entries ?? [],
entriesHydrated: existing?.entriesHydrated ?? false,
busy: false,
permissionMode:
existing?.permissionMode ??
Expand Down Expand Up @@ -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
}
}
}
}
Expand Down
Loading