From 5b181e511a5d5937b4db3e7993798c09b6bdbfd2 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Wed, 9 Sep 2026 19:06:20 -0500 Subject: [PATCH] fix(desktop): keep composer status groups collapsed except todos --- .../src/app/chat/composer/queue-panel.tsx | 6 - .../status-stack/default-collapse.test.tsx | 109 ++++++++++++++++++ .../status-stack/goal-indicator.test.tsx | 7 +- .../app/chat/composer/status-stack/index.tsx | 4 +- .../status-stack/session-control-goal.tsx | 1 - .../status-stack/session-control.test.tsx | 4 +- .../status-stack/subagent-controls.test.tsx | 2 + .../status-stack/subagent-hydration.test.tsx | 1 + .../status-stack/subagent-lifecycle.test.tsx | 2 + .../status-stack/subagent-section.test.tsx | 8 +- .../status-stack/subagent-section.tsx | 1 - 11 files changed, 131 insertions(+), 14 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/status-stack/default-collapse.test.tsx diff --git a/apps/desktop/src/app/chat/composer/queue-panel.tsx b/apps/desktop/src/app/chat/composer/queue-panel.tsx index 6e568dd624..01290bfe1d 100644 --- a/apps/desktop/src/app/chat/composer/queue-panel.tsx +++ b/apps/desktop/src/app/chat/composer/queue-panel.tsx @@ -46,10 +46,6 @@ export function QueuePanel({ } return ( - // Keyed on the park flag: StatusSection owns its collapse state from - // defaultCollapsed, so remount on park/unpark. A Stop must EXPAND the - // panel — the halted prompts' only presence is here, and leaving them - // behind a collapsed "N queued" pill is how they read as vanished. ) : undefined } - defaultCollapsed={!parked} icon={} - key={parked ? 'parked' : 'flowing'} label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)} > {entries.map(entry => { diff --git a/apps/desktop/src/app/chat/composer/status-stack/default-collapse.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/default-collapse.test.tsx new file mode 100644 index 0000000000..b6487038a5 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/status-stack/default-collapse.test.tsx @@ -0,0 +1,109 @@ +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { afterEach, expect, it } from 'vitest' + +import { $backgroundStatusBySession } from '@/store/composer-status' +import { $goalsBySession } from '@/store/goals' +import { $sessionControlBySession } from '@/store/session-control' +import { $subagentsBySession, upsertSubagent } from '@/store/subagents' +import { $todosBySession } from '@/store/todos' + +import { QueuePanel } from '../queue-panel' + +import { ComposerStatusStack } from './index' + +const noop = () => {} + +const queue = (parked: boolean) => ( + +) + +const stack = (parked = false) => ( + + + +) + +afterEach(() => { + cleanup() + $backgroundStatusBySession.set({}) + $goalsBySession.set({}) + $sessionControlBySession.set({}) + $subagentsBySession.set({}) + $todosBySession.set({}) +}) + +it('auto-expands only todos and keeps other groups closed as activity arrives', () => { + $todosBySession.set({ owner: [{ id: 'todo', content: 'Visible todo', status: 'in_progress' }] }) + $goalsBySession.set({ owner: { status: 'active', title: 'Hidden legacy goal', updatedAt: 1 } }) + $backgroundStatusBySession.set({ + owner: [{ id: 'process', type: 'background', state: 'running', title: 'Background process' }] + }) + upsertSubagent('owner', { subagent_id: 'worker', goal: 'Worker task' }) + const view = render(stack()) + + expect(screen.getByText('Visible todo')).toBeTruthy() + + for (const text of ['Hidden legacy goal', 'Background process', 'Worker task', 'Queued request']) { + expect(screen.queryByText(text)).toBeNull() + } + + act(() => upsertSubagent('owner', { subagent_id: 'worker', text: 'Progress arrived' }, false, 'subagent.progress')) + view.rerender(stack(true)) + expect(screen.queryByText('Worker task')).toBeNull() + expect(screen.queryByText('Queued request')).toBeNull() + + const header = screen.getByRole('button', { name: /1 Subagent/ }) + fireEvent.click(header) + expect(screen.getByText('Worker task')).toBeTruthy() + expect(screen.getByText('Progress arrived')).toBeTruthy() + view.rerender(stack(false)) + expect(screen.getByText('Worker task')).toBeTruthy() +}) + +it('starts structured goals collapsed and preserves manual queue expansion when parked', () => { + $sessionControlBySession.set({ + owner: { + capability: 'supported', + error: null, + loading: false, + pendingAction: null, + snapshot: { + goal: { + title: 'Structured goal', + status: 'active', + max_turns: 20, + turns_used: 1, + subgoals: ['Criterion'], + gates: [], + contract: { outcome: '', verification: '', boundaries: '', constraints: '', stop_when: '' } + }, + heartbeat: null, + loop: null, + revision: '1', + updated_at: 1 + } + } + }) + const view = render(stack()) + const goal = screen.getByRole('button', { name: /Goal active/ }) + expect(goal.getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText('Criterion')).toBeNull() + fireEvent.click(goal) + expect(screen.getByText('Criterion')).toBeTruthy() + + const queued = screen.getByRole('button', { name: /1 queued/i }) + fireEvent.click(queued) + expect(screen.getByText('Queued request')).toBeTruthy() + view.rerender(stack(true)) + expect(screen.getByText('Queued request')).toBeTruthy() +}) diff --git a/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx index 5b51223282..9862f15a9b 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/goal-indicator.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { MemoryRouter } from 'react-router' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -57,6 +57,8 @@ describe('ComposerStatusStack goal indicator', () => { renderStack() expect(screen.getByText('Goal active')).toBeTruthy() + expect(screen.queryByText('ship the feature')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Goal (active|paused)/ })) expect(screen.getByText('ship the feature')).toBeTruthy() }) @@ -66,6 +68,8 @@ describe('ComposerStatusStack goal indicator', () => { renderStack() expect(screen.getByText('Goal paused')).toBeTruthy() + expect(screen.queryByText('ship the feature')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: /Goal (active|paused)/ })) expect(screen.getByText('ship the feature')).toBeTruthy() }) @@ -74,6 +78,7 @@ describe('ComposerStatusStack goal indicator', () => { renderStack() + fireEvent.click(screen.getByRole('button', { name: /Goal active/ })) expect(screen.getByText('Continuing toward goal (3/20)')).toBeTruthy() }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index b1e74267dc..727938c1c6 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -241,7 +241,7 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat /> ) : undefined } - defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'} + defaultCollapsed={group.type !== 'todo'} icon={} label={groupLabel(group, t.statusStack)} > @@ -313,7 +313,7 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat composerDockCard('top'), // Inset (mx-2) so the stack reads slightly narrower than the composer // surface below it — the original look. - 'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent pt-0.5' + 'mx-2 overflow-hidden rounded-b-none border-b border-b-transparent' )} >
} - defaultCollapsed={false} icon={} label={headerLabel} > diff --git a/apps/desktop/src/app/chat/composer/status-stack/session-control.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/session-control.test.tsx index c6b4bf0fd5..d768270294 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/session-control.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/session-control.test.tsx @@ -169,7 +169,8 @@ describe('ComposerStatusStack session-control UI', () => { expect(mockRefreshSessionControl).toHaveBeenCalledWith(SID) expect(mockRefreshSessionGoal).not.toHaveBeenCalled() - // Legacy goal is rendered while capability is unknown + // Legacy goal remains available while capability is unknown. + fireEvent.click(screen.getByRole('button', { name: /Goal active/ })) expect(screen.getByText('Legacy Goal Title')).toBeTruthy() }) @@ -187,6 +188,7 @@ describe('ComposerStatusStack session-control UI', () => { renderStack() + fireEvent.click(screen.getByRole('button', { name: /Goal active/ })) expect(screen.getByText('Structured Goal Title')).toBeTruthy() expect(screen.queryByText('Legacy Goal Title')).toBeNull() }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/subagent-controls.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/subagent-controls.test.tsx index 8836b4d3d9..b4f0693a59 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/subagent-controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/subagent-controls.test.tsx @@ -28,6 +28,7 @@ it('sends steer and stop to the child parent owner, never the active gateway or setSessionOwnerHint('parent', { connectionId: 'remote-owner', profile: 'research' }) upsertSubagent('parent', { subagent_id: 'worker', child_session_id: 'child-transcript', goal: 'Owned work' }) render() + fireEvent.click(screen.getByRole('button', { name: /1 Subagent/ })) fireEvent.click(screen.getByRole('button', { name: /Owned work/ })) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Check the negative control' } }) fireEvent.click(screen.getByRole('button', { name: 'Steer' })) @@ -53,6 +54,7 @@ it('keeps rejected steer text and does not retarget when the owner is unknown', const request = vi.spyOn(gateway, 'requestGatewayForAgent').mockResolvedValue({ status: 'rejected' }) upsertSubagent('unknown', { subagent_id: 'worker', goal: 'Unbound work' }) render() + fireEvent.click(screen.getByRole('button', { name: /1 Subagent/ })) fireEvent.click(screen.getByRole('button', { name: /Unbound work/ })) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Keep this instruction' } }) fireEvent.click(screen.getByRole('button', { name: 'Steer' })) diff --git a/apps/desktop/src/app/chat/composer/status-stack/subagent-hydration.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/subagent-hydration.test.tsx index 4f8f47929e..f956670e7a 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/subagent-hydration.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/subagent-hydration.test.tsx @@ -52,6 +52,7 @@ it('hydrates the empty owner composer and exposes an extended owner-routed trans ) + fireEvent.click(await screen.findByRole('button', { name: /1 Subagent/ })) await screen.findByText('Recovered work') expect(screen.getByText('Read File')).toBeTruthy() expect(request).toHaveBeenCalledWith('remote-owner', 'research', 'subagent.list', { session_id: 'parent' }) diff --git a/apps/desktop/src/app/chat/composer/status-stack/subagent-lifecycle.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/subagent-lifecycle.test.tsx index 5b5e79623d..5b9fff4ee1 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/subagent-lifecycle.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/subagent-lifecycle.test.tsx @@ -24,6 +24,7 @@ it('keeps each worker draft while inspecting siblings and removes settled select upsertSubagent('parent', { subagent_id: 'a', goal: 'Worker A' }) upsertSubagent('parent', { subagent_id: 'b', goal: 'Worker B' }) render() + fireEvent.click(screen.getByRole('button', { name: /2 Subagents/ })) fireEvent.click(screen.getByRole('button', { name: /Worker A/ })) fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Preserve my instruction' } }) fireEvent.click(screen.getByRole('button', { name: /Worker B/ })) @@ -40,6 +41,7 @@ it('measures detail elapsed from worker start rather than first inspection', () upsertSubagent('parent', { subagent_id: 'timed', goal: 'Timed worker' }) now.mockReturnValue(117000) const { container } = render() + fireEvent.click(screen.getByRole('button', { name: /1 Subagent/ })) fireEvent.click(screen.getByRole('button', { name: /Timed worker/ })) expect( container.querySelector( diff --git a/apps/desktop/src/app/chat/composer/status-stack/subagent-section.test.tsx b/apps/desktop/src/app/chat/composer/status-stack/subagent-section.test.tsx index f67d564985..e68e655763 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/subagent-section.test.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/subagent-section.test.tsx @@ -36,11 +36,13 @@ it('shows live work only from the composer session and keeps it hidden after col ) - expect(screen.getByText('Task 0')).toBeTruthy() - expect(screen.getByText('Reading actual source')).toBeTruthy() + expect(screen.queryByText('Task 0')).toBeNull() expect(screen.queryByText('Private foreign task')).toBeNull() const header = screen.getByRole('button', { name: /5 Subagents/ }) fireEvent.click(header) + expect(screen.getByText('Task 0')).toBeTruthy() + expect(screen.getByText('Reading actual source')).toBeTruthy() + fireEvent.click(header) expect(screen.queryByText('Task 0')).toBeNull() expect(screen.queryByText('Task 4')).toBeNull() expect(header.getAttribute('aria-expanded')).toBe('false') @@ -67,6 +69,7 @@ it('collapses a single worker and its selected detail using the caret, preservin ) + fireEvent.click(screen.getByRole('button', { name: /1 Subagent/ })) fireEvent.click(screen.getByRole('button', { name: /Single task/ })) expect(view.container.querySelector('[data-slot="composer-subagent-detail"]')).toBeTruthy() const draft = screen.getByRole('textbox') @@ -88,6 +91,7 @@ it('retires the live frame only after every child settles, without depending on ) + fireEvent.click(screen.getByRole('button', { name: /1 Subagent/ })) expect(screen.getByText('Live task')).toBeTruthy() act(() => upsertSubagent('owner', { subagent_id: 'child', status: 'completed' }, false, 'subagent.complete')) expect(screen.queryByText('Live task')).toBeNull() diff --git a/apps/desktop/src/app/chat/composer/status-stack/subagent-section.tsx b/apps/desktop/src/app/chat/composer/status-stack/subagent-section.tsx index 7b08ce55de..186ae1ea1a 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/subagent-section.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/subagent-section.tsx @@ -71,7 +71,6 @@ export function SubagentSection({ sessionId }: SubagentSectionProps) { spinner="braille" /> } - defaultCollapsed={false} icon={} label={t.statusStack.subagents(live.length)} >