The task registry has run every background bash, pwsh, pty-send, and one-shot subagent since it landed, but only the model could read it: a human at the Web client could not see that a build was running, tell a finished task from a stuck one, or find its outcome anywhere but the `run_in_background` tool card that printed an id and never updated. Task state now reaches the browser as one whole-snapshot `session/tasks` mux frame per session, pushed at every registry commit that changes what that session can see. `TaskService` gains `onTasksChanged`, which is owner-granular because owner-disposal removal is a change no per-task record can express. The carrier reads the exact owner the listener hands it, so a push stays correct while that scope tears down, and reads the baseline through the non-resuming `ctx.agents.get` so listing never revives a cold session. The client keeps a last-wins mirror on `SessionListState`, and a new `dsh-client-ui-task` package renders it beside the subagent catalog — rendering nothing at all until the session has a task, so an ordinary conversation grows no new chrome. Streamed per-task output and human-initiated cancellation are separate phases; the note records why neither has to undo this channel, and why no Web path may call the consuming `ctx.tasks.read()`.
68 lines
2.8 KiB
TypeScript
68 lines
2.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
|
import { createSnapshotStore, type SessionListState, type WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import { EnterBehaviorRow } from '../src/client/settings/EnterBehaviorRow.tsx'
|
|
import type { EnterBehaviorRowProps } from '../src/client/settings/EnterBehaviorRow.tsx'
|
|
import { ComposerSubmissionPolicy } from '../src/client/input/submission-policy.ts'
|
|
import { en } from '../src/client/locales.ts'
|
|
|
|
afterEach(() => {
|
|
cleanup()
|
|
localStorage.clear()
|
|
})
|
|
|
|
function emptySessions() {
|
|
return bindSnapshotSelector(createSnapshotStore<SessionListState>({
|
|
ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined,
|
|
}))
|
|
}
|
|
|
|
function emptyWorkspaces() {
|
|
return bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
|
|
items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null,
|
|
baselinesReady: true, recentWorkspaceId: undefined,
|
|
}))
|
|
}
|
|
|
|
function mount() {
|
|
const policy = new ComposerSubmissionPolicy()
|
|
const setBusyEnter = vi.fn((behavior: 'queue' | 'steer') => { policy.setBusyEnter(behavior) })
|
|
const props: EnterBehaviorRowProps = {
|
|
useSessions: emptySessions(),
|
|
useWorkspaces: emptyWorkspaces(),
|
|
useBusyEnter: bindSnapshotSelector(policy.busyEnter),
|
|
setBusyEnter,
|
|
t: makeTranslate(en),
|
|
}
|
|
render(<EnterBehaviorRow {...props} />)
|
|
return { policy, setBusyEnter }
|
|
}
|
|
|
|
describe('EnterBehaviorRow', () => {
|
|
it('explains the busy-only scope and shows Queue by default', () => {
|
|
mount()
|
|
expect(screen.getByText('Enter behavior while busy')).toBeDefined()
|
|
expect(screen.getByText('Busy only; Cmd/Ctrl+Enter uses the other behavior')).toBeDefined()
|
|
expect(screen.getByRole('button', { name: /Queue/ }).getAttribute('aria-expanded')).toBe('false')
|
|
})
|
|
|
|
it('selects Steer, follows later preference changes, and closes outside', () => {
|
|
const b = mount()
|
|
const trigger = screen.getByRole('button', { name: /Queue/ })
|
|
fireEvent.click(trigger)
|
|
fireEvent.click(screen.getByRole('menuitem', { name: 'Steer' }))
|
|
expect(b.setBusyEnter).toHaveBeenCalledWith('steer')
|
|
expect(screen.getByRole('button', { name: /Steer/ })).toBeDefined()
|
|
|
|
act(() => { b.policy.setBusyEnter('queue') })
|
|
const queueTrigger = screen.getByRole('button', { name: /Queue/ })
|
|
fireEvent.click(queueTrigger)
|
|
expect(screen.getByRole('menuitem', { name: 'Steer' })).toBeDefined()
|
|
fireEvent.pointerDown(document.body)
|
|
expect(screen.queryByRole('menuitem', { name: 'Steer' })).toBeNull()
|
|
})
|
|
})
|