Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

This commit is contained in:
creatixchu
2026-07-24 20:01:36 +08:00
107 changed files with 4589 additions and 502 deletions
@@ -80,6 +80,7 @@ async function bench() {
scopeOf,
hostDescription: () => undefined,
create: vi.fn(() => Promise.resolve(ROOT)),
createWorkspace: vi.fn(() => Promise.resolve(ROOT)),
open: vi.fn(),
}
ctx.provide('sessions', sessionsFake)
@@ -241,13 +242,14 @@ describe('details and empty inject surfaces', () => {
expect(details).toBe(conv)
})
it('empty injects draft-image lifecycle and the startSession chain without a store', async () => {
it('empty injects draft-image lifecycle, startSession, and createWorkspaceSession without a store', async () => {
const b = await bench()
const entry = b.entryOf('conversation.empty')
expect(entry.store).toBeUndefined()
const injected = (entry.inject as unknown as () => EmptyStateInjected)()
expect(Object.keys(injected)).toEqual([
expect(Object.keys(injected).sort()).toEqual([
'createDraftImages',
'createWorkspaceSession',
'releaseDraftImage',
'releaseDraftImages',
'startSession',
@@ -256,6 +258,10 @@ describe('details and empty inject surfaces', () => {
expect(b.sessionsFake.create).toHaveBeenCalled()
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue')
b.sessionsFake.open.mockClear()
await injected.createWorkspaceSession('Fresh')
expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh')
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
})
it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => {
@@ -20,7 +20,7 @@ afterEach(cleanup)
const SID = 's1' as SessionId
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }],
...(usage === undefined ? {} : { usage }),
})
@@ -65,7 +65,7 @@ describe('deriveStats', () => {
it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => {
const tool: ToolResultNode = {
kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [],
kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [],
isError: false, callView: null, resultView: null,
}
const stats = deriveStats([tool, assistant(1, 1)])
@@ -112,8 +112,9 @@ describe('bash sample row', () => {
const CHILD = 'child-1' as SessionId
const result = (callId: string): ToolResultNode => ({
kind: 'tool-result', seq: 3, callId,
kind: 'tool-result', seq: 3, time: 3_000, callId,
call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' },
callTime: 2_000,
content: [], isError: false, callView: null, resultView: null,
})
@@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/
const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}',
turn: 1, step: 1, callView: null, ...over,
turn: 1, step: 1, time: 1_000, callView: null, ...over,
})
const result = (over?: Partial<ToolResultNode>): ToolResultNode => ({
kind: 'tool-result', seq: 10, callId: 'c1',
kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null, ...over,
})
@@ -31,8 +31,9 @@ beforeEach(() => {
})
const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: args },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
@@ -54,18 +54,19 @@ function makeSource(init?: Partial<ConversationSnapshot>) {
}
const user = (seq: number, text: string): UserMessageNode => ({
kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null,
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null,
})
const assistant = (seq: number, text: string): AssistantMessageNode => ({
kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }],
})
const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({
kind: 'tool-result', seq, callId,
kind: 'tool-result', seq, time: seq * 1_000, callId,
call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` },
callTime: seq * 1_000 - 500,
content: [], isError: false, callView: null, resultView: null,
})
const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null,
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
})
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
@@ -62,8 +62,9 @@ describe('tails', () => {
it('a settled others-variant row renders the sparkle icon in the leading slot', () => {
const settled: ToolResultNode = {
kind: 'tool-result', seq: 2, callId: 'c5',
kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5',
call: { name: 'todo_write', argsRaw: '{"note":"x"}' },
callTime: 1_000,
content: [], isError: false, callView: null, resultView: null,
}
const props: ToolRowOwnerProps = {
@@ -77,8 +78,9 @@ describe('tails', () => {
it('BashRow shows the failed pill on error results (root session arm)', () => {
const errorResult: ToolResultNode = {
kind: 'tool-result', seq: 1, callId: 'c1',
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"command":"boom"}' },
callTime: 500,
content: [], isError: true, callView: null, resultView: null,
}
// Root session (no parentId): the global arm renders, error pill visible.
@@ -19,7 +19,10 @@ function setup(over?: Partial<InputBarProps>) {
}
const view = render(<InputBar {...props} />)
const textarea = view.container.querySelector('textarea')!
const button = view.container.querySelector('button')!
// aria-label (not role name): title also contains 发送/停止 and would double-match.
const button = view.container.querySelector<HTMLButtonElement>(
`button[aria-label="${over?.running === true ? '停止' : '发送'}"]`,
)!
return { view, textarea, button, props }
}
@@ -97,7 +100,7 @@ describe('running lock and primary button', () => {
const textarea = view.container.querySelector('textarea')!
expect(document.activeElement).toBe(textarea)
textarea.blur()
fireEvent.mouseDown(view.container.querySelector('button')!)
fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!)
expect(document.activeElement).toBe(textarea)
})
@@ -223,3 +226,38 @@ describe('image draft rail', () => {
expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull()
})
})
describe('placeholder chrome', () => {
it('renders attach / Plan / Read-only / model controls', () => {
const { view } = setup()
expect(view.getByLabelText('添加')).toBeTruthy()
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan')
expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly')
expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high')
})
it('native select change updates the selected option', () => {
const { view } = setup()
const plan = view.getByLabelText('Plan mode') as HTMLSelectElement
fireEvent.change(plan, { target: { value: 'agent' } })
expect(plan.value).toBe('agent')
const access = view.getByLabelText('Access mode') as HTMLSelectElement
fireEvent.change(access, { target: { value: 'readwrite' } })
expect(access.value).toBe('readwrite')
})
it('model select can drop the High option', () => {
const { view } = setup()
const model = view.getByLabelText('Model') as HTMLSelectElement
fireEvent.change(model, { target: { value: 'v4-pro' } })
expect(model.value).toBe('v4-pro')
expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro')
})
it('running locks the chrome selects and attach control', () => {
const { view } = setup({ running: true })
expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true)
expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true)
expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true)
})
})
@@ -3,7 +3,7 @@
// acceptance flows), four-share props form: breadcrumb ancestry derivation +
// error strip in ConversationRoot, DetailsPanel non-JSON args / non-text
// result blocks / error-only results over the shared store, EmptyState
// failure surface and custom-directory swap with in-component cwd derivation.
// failure surface and path-modal confirm with in-component cwd derivation.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
@@ -173,7 +173,7 @@ describe('DetailsPanel branches', () => {
it('shows non-JSON args verbatim (streaming fragment path)', () => {
const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, {
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }],
runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }],
})
expect(view.getByText('{"cmd": tru')).toBeTruthy()
})
@@ -184,7 +184,7 @@ describe('DetailsPanel branches', () => {
})
it('snapshot updates re-run the material selector through the shallow equality arm', () => {
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot
let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot
const subs = new Set<() => void>()
const source = {
getSnapshot: () => snap,
@@ -246,6 +246,8 @@ describe('DetailsPanel branches', () => {
})
describe('EmptyState branches', () => {
const noopCreate = () => Promise.resolve()
it('keeps the draft and surfaces a local error strip when startSession rejects', async () => {
const startSession = vi.fn(() => Promise.reject(new Error('create down')))
const view = render(
@@ -255,6 +257,7 @@ describe('EmptyState branches', () => {
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
@@ -273,6 +276,7 @@ describe('EmptyState branches', () => {
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
@@ -281,7 +285,7 @@ describe('EmptyState branches', () => {
await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy())
})
it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => {
it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => {
const startSession = vi.fn(() => Promise.resolve())
const view = render(
<EmptyState
@@ -293,19 +297,42 @@ describe('EmptyState branches', () => {
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const select = view.container.querySelector('select')!
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/proj', '::new-directory'])
fireEvent.change(select, { target: { value: '/proj' } })
expect((select as HTMLSelectElement).value).toBe('/proj')
fireEvent.change(select, { target: { value: '::new-directory' } })
const custom = view.container.querySelector('input')!
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['proj', 'New Workspace'])
fireEvent.click(view.getByRole('menuitem', { name: 'proj' }))
expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj')
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' }))
const custom = view.getByLabelText('Folder path')
fireEvent.change(custom, { target: { value: '/typed/dir' } })
fireEvent.click(view.getByRole('button', { name: 'Open Folder' }))
const textarea = view.container.querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'task' } })
fireEvent.keyDown(textarea, { key: 'Enter' })
await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' }))
})
it('Create modal surfaces inject failures inline', async () => {
const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked')))
const view = render(
<EmptyState
useSessions={listHook([])}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(view.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(view.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(view.getByRole('button', { name: 'Create' }))
await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked'))
})
})
@@ -28,13 +28,33 @@ const sid = (s: string): SessionId => s as SessionId
afterEach(cleanup)
beforeEach(() => {
localStorage.clear()
// jsdom normally provides localStorage; some host Node builds surface it as undefined.
globalThis.localStorage?.clear()
})
/** Minimal conversation snapshot slice the skeleton reads. */
interface FakeSnapshot {
nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[]
runningCalls: readonly { callId: string; name: string; argsRaw: string }[]
nodes: readonly {
kind: string
seq?: number
time?: number
callId?: string
call?: { name: string; argsRaw: string } | null
callTime?: number | null
content?: readonly { type: string; text?: string }[]
isError?: boolean
callView?: null
resultView?: null
}[]
runningCalls: readonly {
callId: string
name: string
argsRaw: string
turn?: number
step?: number
time?: number
callView?: null
}[]
running: boolean
removed: boolean
promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null
@@ -66,6 +86,14 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId?
const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))}</>
describe('EmptyState', () => {
const noopCreate = () => Promise.resolve()
/** Required draft-image lifecycle props for tests not exercising images. */
const noopImages = {
createDraftImages: () => [],
releaseDraftImage: () => {},
releaseDraftImages: () => {},
}
it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => {
const { useSessions } = fakeSessions([
{ id: 'a', title: 'a', cwd: '/w/app' },
@@ -77,18 +105,19 @@ describe('EmptyState', () => {
render(
<EmptyState
useSessions={useSessions}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
{...noopImages}
startSession={startSession}
createWorkspaceSession={noopCreate}
/>,
)
const select = screen.getByRole('combobox', { name: '项目目录' })
expect([...(select as HTMLSelectElement).options].map(o => o.value))
.toEqual(['', '/w/app', '/w/lib', '::new-directory'])
fireEvent.change(select, { target: { value: '/w/app' } })
const box = screen.getByPlaceholderText('Message to run task, plan and build')
const trigger = screen.getByRole('button', { name: '项目目录' })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent))
.toEqual(['app', 'lib', 'New Workspace'])
fireEvent.click(screen.getByRole('menuitem', { name: 'app' }))
const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands')
fireEvent.change(box, { target: { value: '造一个轮子' } })
fireEvent.keyDown(box, { key: 'Enter' })
expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' })
@@ -99,21 +128,67 @@ describe('EmptyState', () => {
expect((box as HTMLTextAreaElement).value).toBe('造一个轮子')
})
it('new-directory option swaps the select for a free-form input', () => {
it('Use a existing folder opens the path modal and Open Folder sets the chip', () => {
const { useSessions } = fakeSessions([])
render(
<EmptyState
useSessions={useSessions}
createDraftImages={() => []}
releaseDraftImage={() => {}}
releaseDraftImages={() => {}}
{...noopImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } })
const custom = screen.getByPlaceholderText(/目录路径/)
fireEvent.change(custom, { target: { value: '/tmp/fresh' } })
expect((custom as HTMLInputElement).value).toBe('/tmp/fresh')
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
const newWs = screen.getByRole('menuitem', { name: 'New Workspace' })
fireEvent.mouseEnter(newWs.parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' }))
expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy()
const path = screen.getByLabelText('Folder path') as HTMLInputElement
fireEvent.change(path, { target: { value: '/tmp/fresh' } })
fireEvent.click(screen.getByRole('button', { name: 'Open Folder' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh')
})
it('Create new opens the modal and createWorkspaceSession succeeds', async () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
{...noopImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy()
const name = screen.getByLabelText('Workspace name') as HTMLInputElement
expect(name.value).toBe('New WorkSpace')
fireEvent.change(name, { target: { value: 'My Proj' } })
fireEvent.keyDown(name, { key: 'Enter' })
await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj'))
})
it('Create modal Cancel dismisses without calling createWorkspaceSession', () => {
const { useSessions } = fakeSessions([])
const createWorkspaceSession = vi.fn(() => Promise.resolve())
render(
<EmptyState
useSessions={useSessions}
{...noopImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={createWorkspaceSession}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '项目目录' }))
fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement)
fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' }))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(createWorkspaceSession).not.toHaveBeenCalled()
})
it('routes empty-state draft image creation and release through the injected lifecycle', () => {
@@ -137,6 +212,7 @@ describe('EmptyState', () => {
releaseDraftImage={releaseDraftImage}
releaseDraftImages={releaseDraftImages}
startSession={() => Promise.resolve()}
createWorkspaceSession={noopCreate}
/>,
)
const textarea = view.container.querySelector('textarea')!
@@ -293,10 +369,11 @@ describe('DetailsPanel', () => {
it('renders the selected call args and result off the shared store; close fires the injected callback', () => {
const { closeDetails } = benchDetails({
nodes: [{
kind: 'tool-result', callId: 'c1',
kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1',
call: { name: 'bash', argsRaw: '{"cmd":"ls"}' },
callTime: 500,
content: [{ type: 'text', text: 'file-a\nfile-b' }],
isError: false,
isError: false, callView: null, resultView: null,
}],
}, { turnSeq: 1, callId: 'c1' })
expect(screen.getByText('bash')).toBeTruthy()
@@ -307,10 +384,10 @@ describe('DetailsPanel', () => {
})
it('shows the empty hint without a selection and the running state for open calls', () => {
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null)
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null)
expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy()
cleanup()
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' })
benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' })
expect(screen.getByText('运行中…')).toBeTruthy()
})