fix(desktop): keep composer status groups collapsed except todos
This commit is contained in:
@@ -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.
|
||||
<StatusSection
|
||||
accessory={
|
||||
parked ? (
|
||||
@@ -66,9 +62,7 @@ export function QueuePanel({
|
||||
</Tip>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={!parked}
|
||||
icon={<Codicon className="text-muted-foreground/70" name={parked ? 'debug-pause' : 'layers'} size="0.8rem" />}
|
||||
key={parked ? 'parked' : 'flowing'}
|
||||
label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)}
|
||||
>
|
||||
{entries.map(entry => {
|
||||
|
||||
@@ -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) => (
|
||||
<QueuePanel
|
||||
busy
|
||||
editingId={null}
|
||||
entries={[{ attachments: [], id: 'queued', queuedAt: 1, text: 'Queued request' }]}
|
||||
onDelete={noop}
|
||||
onEdit={noop}
|
||||
onResume={noop}
|
||||
onSendNow={noop}
|
||||
parked={parked}
|
||||
/>
|
||||
)
|
||||
|
||||
const stack = (parked = false) => (
|
||||
<MemoryRouter>
|
||||
<ComposerStatusStack queue={queue(parked)} sessionId="owner" />
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
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()
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
defaultCollapsed={group.type !== 'todo' && group.type !== 'goal'}
|
||||
defaultCollapsed={group.type !== 'todo'}
|
||||
icon={<Codicon className="text-muted-foreground/70" name={GROUP_ICON[group.type]} size="0.8rem" />}
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -324,7 +324,6 @@ export const SessionControlGoalSection = memo(function SessionControlGoalSection
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
}
|
||||
defaultCollapsed={false}
|
||||
icon={<Codicon className={iconClass} name="target" size="0.8rem" />}
|
||||
label={headerLabel}
|
||||
>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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(<SubagentSection sessionId="parent" />)
|
||||
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(<SubagentSection sessionId="unknown" />)
|
||||
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' }))
|
||||
|
||||
@@ -52,6 +52,7 @@ it('hydrates the empty owner composer and exposes an extended owner-routed trans
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
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' })
|
||||
|
||||
@@ -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(<SubagentSection sessionId="parent" />)
|
||||
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(<SubagentSection sessionId="parent" />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /1 Subagent/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Timed worker/ }))
|
||||
expect(
|
||||
container.querySelector(
|
||||
|
||||
@@ -36,11 +36,13 @@ it('shows live work only from the composer session and keeps it hidden after col
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
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
|
||||
</MemoryRouter>
|
||||
)
|
||||
|
||||
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
|
||||
<ComposerStatusStack queue={null} sessionId="owner" />
|
||||
</MemoryRouter>
|
||||
)
|
||||
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()
|
||||
|
||||
@@ -71,7 +71,6 @@ export function SubagentSection({ sessionId }: SubagentSectionProps) {
|
||||
spinner="braille"
|
||||
/>
|
||||
}
|
||||
defaultCollapsed={false}
|
||||
icon={<Codicon className="text-(--ui-purple)" name="agent" size="0.8rem" />}
|
||||
label={t.statusStack.subagents(live.length)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user