feat(web): add plan mode controls
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
|
||||
@@ -37,6 +37,7 @@ const CLIENT_PACKAGES = [
|
||||
'@deepseek-ai/dsh-client-ui-layout',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-plan',
|
||||
'@deepseek-ai/dsh-client-ui-question',
|
||||
'@deepseek-ai/dsh-client-ui-trajectory',
|
||||
] as const
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
{
|
||||
"path": "../../packages/client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-plan"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-trajectory"
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
|
||||
{ id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-plan', dir: 'ui-plan', url: '/plugins/ui-plan.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
|
||||
@@ -77,7 +78,8 @@ function titleSurfaces(label: string): { sidebar: string; breadcrumb: string; do
|
||||
return { sidebar, breadcrumb, documentTitle: document.title }
|
||||
}
|
||||
|
||||
it('projects initial and revised durable titles through the built eight-plugin fixture app', async () => {
|
||||
/** Boot the built fixture graph into the per-test root. */
|
||||
function bootFixtureApp(): void {
|
||||
const root = document.querySelector<HTMLElement>('#root')
|
||||
if (root === null) throw new Error('snapshot root missing')
|
||||
act(() => {
|
||||
@@ -89,7 +91,10 @@ it('projects initial and revised durable titles through the built eight-plugin f
|
||||
executeBundle: (code) => { (0, eval)(code) },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
it('projects initial and revised durable titles through the built nine-plugin fixture app', async () => {
|
||||
bootFixtureApp()
|
||||
const projectLabel = await screen.findByText('fixture', {}, { timeout: 10_000 })
|
||||
const projectRow = projectLabel.closest<HTMLElement>('[role="treeitem"]')
|
||||
if (projectRow === null) throw new Error('fixture project row missing')
|
||||
@@ -112,3 +117,53 @@ it('projects initial and revised durable titles through the built eight-plugin f
|
||||
await expect(`${JSON.stringify({ initial, revised }, null, 2)}\n`)
|
||||
.toMatchFileSnapshot('./snapshots/session-title.json')
|
||||
})
|
||||
|
||||
it('snapshots pending and committed plan targets without implicit turn cancellation', async () => {
|
||||
bootFixtureApp()
|
||||
await screen.findByText('fixture', {}, { timeout: 10_000 })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
|
||||
const select: HTMLSelectElement = await screen.findByRole('combobox', { name: '协作模式' }, { timeout: 10_000 })
|
||||
const capture = (stage: string) => {
|
||||
const chip = select.parentElement?.querySelector('span')?.textContent ?? ''
|
||||
return {
|
||||
stage,
|
||||
label: chip,
|
||||
value: select.value,
|
||||
title: select.parentElement?.getAttribute('title') ?? '',
|
||||
primary: screen.getByRole('button', { name: /^(发送|停止)$/ }).getAttribute('aria-label'),
|
||||
}
|
||||
}
|
||||
|
||||
const initial = capture('initial')
|
||||
fireEvent.change(select, { target: { value: 'plan' } })
|
||||
await screen.findByTitle(/计划模式将在下一次模型请求时生效/)
|
||||
const planPending = capture('plan-pending')
|
||||
|
||||
const input = screen.getByPlaceholderText(/输入消息/)
|
||||
fireEvent.change(input, { target: { value: 'commit plan' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
await screen.findByTitle('当前为计划模式')
|
||||
const planCommitted = capture('plan-committed')
|
||||
|
||||
fireEvent.change(select, { target: { value: 'default' } })
|
||||
await screen.findByTitle(/默认模式将在下一次模型请求时生效/)
|
||||
const defaultPendingWhileRunning = capture('default-pending-running')
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }))
|
||||
await waitFor(() => { expect(screen.getByRole('button', { name: '发送' })).toBeTruthy() })
|
||||
const defaultPendingAfterStop = capture('default-pending-stopped')
|
||||
|
||||
fireEvent.change(input, { target: { value: 'commit default' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }))
|
||||
await screen.findByTitle('当前为默认模式')
|
||||
const defaultCommitted = capture('default-committed')
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }))
|
||||
|
||||
await expect(`${JSON.stringify({
|
||||
initial,
|
||||
planPending,
|
||||
planCommitted,
|
||||
defaultPendingWhileRunning,
|
||||
defaultPendingAfterStop,
|
||||
defaultCommitted,
|
||||
}, null, 2)}\n`).toMatchFileSnapshot('./snapshots/plan-mode.json')
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Keyless boot-chain smoke over the REAL carrier: startWebServer + entry
|
||||
// graph (__DSH_BOOT__ web2 shape) injection + built shell dist in a real
|
||||
// chromium. First describe: graph injection + the fail-loud half. Second
|
||||
// describe: the settled success pass — all nine REAL tsdown bundles load
|
||||
// describe: the settled success pass — all ten REAL tsdown bundles load
|
||||
// through the module system + vendored Loader chain in ?fixture mode (the
|
||||
// infrastructure four ride the immediately prefetch tier, the UI rows fetch
|
||||
// on demand), the three-column frame appears in one flip, and the resident
|
||||
@@ -31,6 +31,7 @@ const REAL_PLUGINS: { id: string; dir: string; inject?: string[]; immediately?:
|
||||
{ id: LAYOUT_ID, dir: 'ui-layout', inject: ['@deepseek-ai/dsh-client-runtime'] },
|
||||
{ id: SIDEBAR_ID, dir: 'ui-sidebar', inject: [LAYOUT_ID] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', inject: [LAYOUT_ID] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-plan', dir: 'ui-plan', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-question', dir: 'ui-question', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
{ id: '@deepseek-ai/dsh-client-ui-trajectory', dir: 'ui-trajectory', inject: ['@deepseek-ai/dsh-client-ui-conversation'] },
|
||||
]
|
||||
@@ -118,7 +119,7 @@ describe('web boot chain (keyless, real carrier)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', () => {
|
||||
describe('web boot chain success pass (keyless, ten real bundles, ?fixture)', () => {
|
||||
let server: Awaited<ReturnType<typeof startWebServer>>
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -246,6 +247,31 @@ describe('web boot chain success pass (keyless, nine real bundles, ?fixture)', (
|
||||
expect(await external.getAttribute('rel')).toBe('noopener noreferrer')
|
||||
})
|
||||
|
||||
it('queues plan targets without cancelling a running turn and commits them at prompt boundaries', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-plan-mode'))
|
||||
const select = page.getByRole('combobox', { name: '协作模式' })
|
||||
await select.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await expect(page.getByTitle('当前为默认模式').isVisible()).resolves.toBe(true)
|
||||
|
||||
await select.selectOption('plan')
|
||||
await page.getByTitle(/计划模式将在下一次模型请求时生效/).waitFor()
|
||||
const input = page.locator('textarea[placeholder]')
|
||||
await input.fill('commit plan mode')
|
||||
await page.getByRole('button', { name: '发送' }).click()
|
||||
await page.getByTitle('当前为计划模式').waitFor()
|
||||
|
||||
await select.selectOption('default')
|
||||
await page.getByTitle(/默认模式将在下一次模型请求时生效/).waitFor()
|
||||
await expect(page.getByRole('button', { name: '停止' }).isVisible()).resolves.toBe(true)
|
||||
await page.getByRole('button', { name: '停止' }).click()
|
||||
await page.getByTitle(/默认模式将在下一次模型请求时生效/).waitFor()
|
||||
|
||||
await input.fill('commit default mode')
|
||||
await page.getByRole('button', { name: '发送' }).click()
|
||||
await page.getByTitle('当前为默认模式').waitFor()
|
||||
await page.getByRole('button', { name: '停止' }).click()
|
||||
})
|
||||
|
||||
it('renders and completes the resident question through the composer slot', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'smoke-question-composer'))
|
||||
const sessionTree = page.getByRole('tree', { name: 'Sessions' })
|
||||
|
||||
@@ -144,10 +144,13 @@ async function detailsTrack(page: Page): Promise<number> {
|
||||
return Number(cols.split(' ').pop()!.replace('px', ''))
|
||||
}
|
||||
|
||||
// Readiness gate: `dsh web` serves ALL nine manifest plugins; until every UI
|
||||
// Readiness gate: `dsh web` serves ALL ten manifest plugins; until every UI
|
||||
// plugin's client bundle exists and exports apply, the loader fail-louds and
|
||||
// the frame never appears.
|
||||
const UI_PLUGIN_DIRS = ['connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout', 'ui-sidebar', 'ui-conversation', 'ui-question', 'ui-trajectory']
|
||||
const UI_PLUGIN_DIRS = [
|
||||
'connection', 'runtime', 'ui-theme', 'i18n', 'ui-layout',
|
||||
'ui-sidebar', 'ui-conversation', 'ui-plan', 'ui-question', 'ui-trajectory',
|
||||
]
|
||||
const ROUND_DONE_MARKER = 'WEB_ROUND_DONE'
|
||||
const notReady = UI_PLUGIN_DIRS.filter((dir) => {
|
||||
const bundle = join(REPO_ROOT, 'packages/client', dir, 'lib/client.js')
|
||||
@@ -177,6 +180,9 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
const readyUrl = await waitForReadyLine(child)
|
||||
expect(readyUrl).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/)
|
||||
expect((await fetch(readyUrl)).status).toBe(200)
|
||||
const created = await rpc<{ sessionId: string }>(readyUrl, 'session.create', {})
|
||||
expect(await rpc(readyUrl, 'session.planMode', { sessionId: created.sessionId }))
|
||||
.toEqual({ active: false })
|
||||
} finally {
|
||||
const closed = child.exitCode === null
|
||||
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||
@@ -187,7 +193,7 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('injects the invoking workspace AGENTS.md into the provider request', async () => {
|
||||
it('injects workspace instructions and the active Web plan policy into the provider request', async () => {
|
||||
requireDist()
|
||||
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-'))
|
||||
mkdirSync(join(workspace, '.git'))
|
||||
@@ -235,6 +241,9 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
try {
|
||||
const baseUrl = await waitForReadyLine(child)
|
||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
||||
expect(await rpc(baseUrl, 'session.setPlanMode', {
|
||||
sessionId: created.sessionId, active: true,
|
||||
})).toEqual({ active: false, pending: true })
|
||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||
sessionId: created.sessionId,
|
||||
mode: 'queue',
|
||||
@@ -248,6 +257,11 @@ describe('dsh web keyless CLI smoke', () => {
|
||||
])
|
||||
const workspaceMessage = captured.messages?.find(message =>
|
||||
message.role === 'user' && message.content?.includes('web-workspace-context-probe'))
|
||||
const systemMessage = captured.messages?.find(message => message.role === 'system')
|
||||
expect(systemMessage?.content).toContain('Stay in plan mode until exit_plan_mode succeeds')
|
||||
expect(systemMessage?.content).toContain('Do not edit or write files')
|
||||
expect(await rpc(baseUrl, 'session.planMode', { sessionId: created.sessionId }))
|
||||
.toEqual({ active: true })
|
||||
expect(workspaceMessage).toMatchInlineSnapshot(`
|
||||
{
|
||||
"content": "<system-reminder>
|
||||
|
||||
44
apps/web/tests/snapshots/plan-mode.json
Normal file
44
apps/web/tests/snapshots/plan-mode.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"initial": {
|
||||
"stage": "initial",
|
||||
"label": "默认",
|
||||
"value": "default",
|
||||
"title": "当前为默认模式",
|
||||
"primary": "发送"
|
||||
},
|
||||
"planPending": {
|
||||
"stage": "plan-pending",
|
||||
"label": "计划 · 待生效",
|
||||
"value": "plan",
|
||||
"title": "当前为默认模式;计划模式将在下一次模型请求时生效",
|
||||
"primary": "发送"
|
||||
},
|
||||
"planCommitted": {
|
||||
"stage": "plan-committed",
|
||||
"label": "计划",
|
||||
"value": "plan",
|
||||
"title": "当前为计划模式",
|
||||
"primary": "停止"
|
||||
},
|
||||
"defaultPendingWhileRunning": {
|
||||
"stage": "default-pending-running",
|
||||
"label": "默认 · 待生效",
|
||||
"value": "default",
|
||||
"title": "当前为计划模式;默认模式将在下一次模型请求时生效",
|
||||
"primary": "停止"
|
||||
},
|
||||
"defaultPendingAfterStop": {
|
||||
"stage": "default-pending-stopped",
|
||||
"label": "默认 · 待生效",
|
||||
"value": "default",
|
||||
"title": "当前为计划模式;默认模式将在下一次模型请求时生效",
|
||||
"primary": "发送"
|
||||
},
|
||||
"defaultCommitted": {
|
||||
"stage": "default-committed",
|
||||
"label": "默认",
|
||||
"value": "default",
|
||||
"title": "当前为默认模式",
|
||||
"primary": "停止"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user