Skip to content
Open
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
10 changes: 8 additions & 2 deletions apps/desktop/src/app/chat/composer/hooks/use-status-presence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@ import { useSyncExternalStore } from 'react'
import { $composerActionsBySession } from '@/store/composer-actions'
import { $statusItemsBySession } from '@/store/composer-status'
import { $previewStatusBySession } from '@/store/preview-status'
import { $todoHistoryBySession } from '@/store/todos'

/** Structural view of the three per-session feeds — they hold different item
/** Structural view of the per-session feeds — they hold different item
* types, and all this hook needs from each is "does this key have rows". */
interface PresenceFeed {
get(): Record<string, undefined | unknown[]>
listen(listener: () => void): () => void
}

const FEEDS: PresenceFeed[] = [$statusItemsBySession, $composerActionsBySession, $previewStatusBySession]
const FEEDS: PresenceFeed[] = [
$statusItemsBySession,
$composerActionsBySession,
$previewStatusBySession,
$todoHistoryBySession
]

const subscribe = (onChange: () => void) => {
const offs = FEEDS.map(feed => feed.listen(onChange))
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/app/chat/composer/status-stack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ import {
import { refreshSessionGoal } from '@/store/goals'
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
import { $threadScrolledUp } from '@/store/thread-scroll'
import { $todoHistoryBySession } from '@/store/todos'
import { openSessionInNewWindow } from '@/store/windows'

import { PreviewStatusRow } from './preview-row'
import { StatusItemRow } from './status-row'
import { TaskHistoryList } from './task-history-list'

// Slow safety-net poll for silent exits (processes without notify_on_complete
// emit no event when they die). Only armed while a running row is on screen.
Expand Down Expand Up @@ -93,6 +95,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const items = useSessionSlice($statusItemsBySession, sessionId)
const previews = useSessionSlice($previewStatusBySession, sessionId)
const scrolledUp = useStore($threadScrolledUp)
const taskHistory = useSessionSlice($todoHistoryBySession, sessionId)
const billing = useStore($billingBlock)

const groups = useMemo(() => groupStatusItems(items), [items])
Expand Down Expand Up @@ -150,6 +153,20 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
}

const historySection =
taskHistory.length > 0 ? (
<StatusSection
icon={<Codicon className="text-muted-foreground/70" name="history" size="0.8rem" />}
label={t.statusStack.taskHistory}
>
<TaskHistoryList snapshots={taskHistory} />
</StatusSection>
) : null

if (historySection && !groups.some(group => group.type === 'todo')) {
sections.push({ key: 'task-history', node: historySection })
}

for (const group of groups) {
sections.push({
key: group.type,
Expand Down Expand Up @@ -187,6 +204,10 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
)
})

if (group.type === 'todo' && historySection) {
sections.push({ key: 'task-history', node: historySection })
}

// Preview links belong to the background group (a localhost dev server and
// its preview are the same thing), but they must stay VISIBLE even when that
// group is collapsed — the whole point is a one-tap open. Render them as an
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Fragment } from 'react'

import { Codicon } from '@/components/ui/codicon'
import { useI18n } from '@/i18n'
import type { TodoHistorySnapshot } from '@/lib/todos'
import type { ComposerStatusItem } from '@/store/composer-status'

import { StatusItemRow } from './status-row'

interface TaskHistoryListProps {
snapshots: readonly TodoHistorySnapshot[]
}

const historyItem = (snapshotId: string, todo: TodoHistorySnapshot['todos'][number]): ComposerStatusItem => ({
id: `${snapshotId}:${todo.id}`,
state: todo.status === 'in_progress' ? 'running' : 'done',
title: todo.content,
todoStatus: todo.status,
type: 'todo'
})

/** Compact transcript-derived plans. The store owns ordering, retention, and
* deduplication; this component only renders the selected session's slice. */
export function TaskHistoryList({ snapshots }: TaskHistoryListProps) {
const { t } = useI18n()

return (
<div className="space-y-1 pb-0.5">
{snapshots.map(snapshot => (
<Fragment key={snapshot.id}>
<div className="flex items-center gap-1.5 px-1.5 pt-1 text-[0.62rem] font-medium text-muted-foreground/70">
<Codicon aria-hidden name={snapshot.state === 'completed' ? 'pass' : 'history'} size="0.72rem" />
<span>
{snapshot.state === 'completed'
? t.statusStack.taskHistoryCompleted
: t.statusStack.taskHistoryUnfinished}
</span>
</div>
{snapshot.todos.map(todo => (
<StatusItemRow item={historyItem(snapshot.id, todo)} key={`${snapshot.id}:${todo.id}`} />
))}
</Fragment>
))}
</div>
)
}
179 changes: 179 additions & 0 deletions apps/desktop/src/app/chat/composer/status-stack/task-history.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { atom } from 'nanostores'
import type { ReactNode } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'

import { I18nProvider } from '@/i18n'
import type { TodoHistorySnapshot, TodoItem } from '@/lib/todos'
import { $messages } from '@/store/session'
import {
$todoHistoryBySession,
clearAllSessionTodoState,
rebuildSessionTodoHistory,
setSessionTodos
} from '@/store/todos'

import { ComposerStatusStack } from '.'

class ResizeObserverStub {
disconnect() {}
observe() {}
}

const todo = (id: string, content: string, status: TodoItem['status'] = 'completed'): TodoItem => ({
content,
id,
status
})

const snapshot = (id: string, content: string): TodoHistorySnapshot => ({
id,
state: 'completed',
todos: [todo(id, content)]
})

function renderStack(sessionId: string, wrapper?: (children: ReactNode) => ReactNode) {
const stack = <ComposerStatusStack queue={null} sessionId={sessionId} />

return render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
{wrapper ? wrapper(stack) : stack}
</I18nProvider>
</MemoryRouter>
)
}

describe('composer task history', () => {
beforeAll(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
})

afterEach(() => {
cleanup()
clearAllSessionTodoState()
vi.useRealTimers()
vi.restoreAllMocks()
})

it('keeps task history available after the finished live list dismisses at four seconds', () => {
vi.useFakeTimers()
$todoHistoryBySession.set({ sid: [snapshot('old', 'Historical task')] })
setSessionTodos('sid', [todo('live', 'Live task')])

const view = renderStack('sid')

expect(screen.getByText('Live task')).toBeTruthy()
const historyButton = screen.getByRole('button', { name: 'Task history' })
expect(historyButton.getAttribute('aria-expanded')).toBe('false')
// Collapsed: the body is unmounted, so aria-controls must not dangle at a
// missing id.
expect(historyButton.getAttribute('aria-controls')).toBeNull()

act(() => vi.advanceTimersByTime(4_000))

expect(screen.queryByText('Live task')).toBeNull()
expect(screen.getByRole('button', { name: 'Task history' })).toBeTruthy()

fireEvent.click(screen.getByRole('button', { name: 'Task history' }))
const expandedButton = screen.getByRole('button', { name: 'Task history' })
expect(screen.getByText('Historical task')).toBeTruthy()
expect(expandedButton.getAttribute('aria-expanded')).toBe('true')
// Expanded: aria-controls now points at the mounted region.
const controlledId = expandedButton.getAttribute('aria-controls') ?? ''
expect(controlledId).toBeTruthy()
expect(view.container.querySelector(`[id="${controlledId}"]`)).toBeTruthy()
})

it('renders the live list first and keeps a large history in a separate collapsed section', () => {
const history = Array.from({ length: 10 }, (_, index) => snapshot(`history-${index}`, `History ${index}`))
$todoHistoryBySession.set({ sid: history })
setSessionTodos('sid', [todo('live', 'Live now', 'in_progress')])

const view = renderStack('sid')
const live = screen.getByText('Live now')
const historyButton = screen.getByRole('button', { name: 'Task history' })

expect(live).toBeTruthy()
expect(historyButton.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('History 0')).toBeNull()
expect(live.compareDocumentPosition(historyButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(view.container.querySelectorAll('button[aria-expanded]')).toHaveLength(2)
})

it('shows only the newest copy of the same id and content when status changed', () => {
rebuildSessionTodoHistory('sid', [
{
id: 'older',
role: 'assistant',
parts: [
{
type: 'tool-call',
toolName: 'todo',
toolCallId: 'todo-old',
args: { todos: [todo('same', 'One plan', 'in_progress')] }
}
]
},
{
id: 'newer',
role: 'assistant',
parts: [
{
type: 'tool-call',
toolName: 'todo',
toolCallId: 'todo-new',
result: { todos: [todo('same', 'One plan', 'completed')] }
}
]
}
])

renderStack('sid')
fireEvent.click(screen.getByRole('button', { name: 'Task history' }))

expect(screen.getAllByText('One plan')).toHaveLength(1)
})

it('isolates task history between two session views', () => {
$todoHistoryBySession.set({
'runtime-a': [snapshot('a', 'Only A')],
'runtime-b': [snapshot('b', 'Only B')]
})

const view = render(
<MemoryRouter>
<I18nProvider configClient={null} initialLocale="en">
<section aria-label="Session A">
<ComposerStatusStack queue={null} sessionId="runtime-a" />
</section>
<section aria-label="Session B">
<ComposerStatusStack queue={null} sessionId="runtime-b" />
</section>
</I18nProvider>
</MemoryRouter>
)

const sessionA = within(view.getByRole('region', { name: 'Session A' }))
const sessionB = within(view.getByRole('region', { name: 'Session B' }))
fireEvent.click(sessionA.getByRole('button', { name: 'Task history' }))

expect(sessionA.getByText('Only A')).toBeTruthy()
expect(sessionA.queryByText('Only B')).toBeNull()
expect(sessionB.queryByText('Only A')).toBeNull()
expect(sessionB.queryByText('Only B')).toBeNull()

fireEvent.click(sessionB.getByRole('button', { name: 'Task history' }))
expect(sessionB.getByText('Only B')).toBeTruthy()
})

it('does not subscribe the composer status surface to transcript messages', () => {
const listen = vi.spyOn($messages, 'listen')
const unrelatedMessages = atom([{ id: 'message', role: 'user' }])

renderStack('sid', children => <div data-unrelated-message-count={unrelatedMessages.get().length}>{children}</div>)

expect(listen).not.toHaveBeenCalled()
})
})
Loading
Loading