feat(shortcuts): add fixed stop and approval actions
Add scoped double-Escape cancellation and fixed approval actions. Reserve their input combinations and preserve composition, modal, and input-owner arbitration. Co-authored-by: 神河一号 - 烈焰流光 <[email protected]>
This commit is contained in:
1 parent
f54744e35b
commit
b7ac0ade10
30 files changed
+734
-28
No files matched your search
@@ -30,7 +30,7 @@ const PROMPT = `Write a file named notes.txt in the workspace containing exactly
|
||||
/** Draft used to measure the composer's own text cap: enough lines to pass it. */
|
||||
const CAP_PROBE = Array.from({ length: 40 }, (_, index) => `line ${index}`).join('\n')
|
||||
|
||||
describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
describe.each(MODE === 'record' ? ['button'] as const : ['button', 'keyboard'] as const)('web e2e: approval takeover through %s', (method) => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -119,7 +119,11 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
|
||||
await page.setViewportSize(original)
|
||||
}
|
||||
|
||||
await panel.getByRole('button', { name: 'Allow once' }).click()
|
||||
if (method === 'button') await panel.getByRole('button', { name: 'Allow once' }).click()
|
||||
else {
|
||||
await scroll.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
|
||||
@@ -108,6 +108,9 @@
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
@@ -120,3 +123,12 @@
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Allow once
|
||||
- button "Allow once Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
@@ -109,6 +109,9 @@
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
@@ -121,3 +124,12 @@
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Allow once
|
||||
- button "Allow once Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
@@ -109,6 +109,9 @@
|
||||
- listitem:
|
||||
- text: Open reference menu
|
||||
- button "Open reference menu @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: Stop generating
|
||||
- button "Stop generating Esc Esc" [disabled]: Esc Esc
|
||||
- region "Menus and dialogs":
|
||||
- heading "Menus and dialogs" [level=3]
|
||||
- list:
|
||||
@@ -121,3 +124,12 @@
|
||||
- listitem:
|
||||
- text: Close menu or top dialog
|
||||
- button "Close menu or top dialog Esc" [disabled]: Esc
|
||||
- region "Approval area":
|
||||
- heading "Approval area" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: Allow once
|
||||
- button "Allow once Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: Reject
|
||||
- button "Reject Esc" [disabled]: Esc
|
||||
@@ -108,6 +108,9 @@
|
||||
- listitem:
|
||||
- text: 打开引用菜单
|
||||
- button "打开引用菜单 @" [disabled]: "@"
|
||||
- listitem:
|
||||
- text: 停止生成
|
||||
- button "停止生成 Esc Esc" [disabled]: Esc Esc
|
||||
- region "菜单与弹层":
|
||||
- heading "菜单与弹层" [level=3]
|
||||
- list:
|
||||
@@ -120,3 +123,12 @@
|
||||
- listitem:
|
||||
- text: 关闭菜单或顶层弹窗
|
||||
- button "关闭菜单或顶层弹窗 Esc" [disabled]: Esc
|
||||
- region "审批区域":
|
||||
- heading "审批区域" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- text: 允许一次
|
||||
- button "允许一次 Enter" [disabled]: Enter
|
||||
- listitem:
|
||||
- text: 拒绝
|
||||
- button "拒绝 Esc" [disabled]: Esc
|
||||
@@ -76,7 +76,7 @@ describe('web e2e: queue row actions', () => {
|
||||
await expect.poll(() => row.getByRole('button', { name: 'Remove queued message' }).isEnabled()).toBe(true)
|
||||
}
|
||||
|
||||
it.skipIf(MODE === 'record')('edits and removes exact occurrences and preserves Queue across stop', async () => {
|
||||
it.skipIf(MODE === 'record').each(['button', 'keyboard'] as const)('edits and removes exact occurrences and preserves Queue across %s stop', async (method) => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-queue-actions-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
@@ -282,7 +282,12 @@ describe('web e2e: queue row actions', () => {
|
||||
const stopButton = page.getByRole('button', { name: 'Stop generating' })
|
||||
await stopButton.hover()
|
||||
await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor()
|
||||
await stopButton.click()
|
||||
if (method === 'button') await stopButton.click()
|
||||
else {
|
||||
await input.focus()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await firstSettled
|
||||
await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count())
|
||||
.toBe(0)
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Fixed approval keys through the real browser, Remote waterfall and recorded model command. */
|
||||
import { access, readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Browser, type Page } from 'playwright'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { fixtureUserPrompts, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/approval-composer/session.v3.jsonl', import.meta.url))
|
||||
|
||||
describe('web e2e: fixed approval rejection keys', () => {
|
||||
let scaffold: WebScaffold | undefined
|
||||
let browser: Browser | undefined
|
||||
let page: Page
|
||||
afterEach(async () => {
|
||||
const results = await Promise.allSettled([browser?.close(), scaffold?.close()])
|
||||
browser = undefined
|
||||
scaffold = undefined
|
||||
const failed = results.filter(result => result.status === 'rejected')
|
||||
if (failed.length > 0) throw new AggregateError(failed.map(result => result.reason as unknown), 'fixed shortcut teardown failed')
|
||||
})
|
||||
|
||||
it.skipIf(webSnapshotMode() === 'record').each(['reject-button-enter', 'container-escape'] as const)(
|
||||
'rejects through %s without running the requested write', async (method) => {
|
||||
const events: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
await page.locator('[aria-label^="Access mode"]').click()
|
||||
await page.getByRole('menuitem', { name: 'Read Only' }).click()
|
||||
await page.locator('[aria-label="Access mode, current: Read Only"]').waitFor()
|
||||
const input = page.locator('[data-composer-input]').first()
|
||||
const prompts = fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))
|
||||
expect(prompts).toHaveLength(1)
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(prompts[0]!)
|
||||
await input.press('Enter')
|
||||
const panel = page.locator('[data-approval-key]')
|
||||
await panel.waitFor()
|
||||
if (method === 'reject-button-enter') {
|
||||
await panel.getByRole('button', { name: 'Reject', exact: true }).focus()
|
||||
await page.keyboard.press('Enter')
|
||||
} else {
|
||||
await panel.locator('[data-approval-scroll]').focus()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await settled
|
||||
expect(events.filter(event => event.type === 'approval/decided').map(event => event.data))
|
||||
.toMatchObject([{ outcome: 'rejected' }])
|
||||
expect(events.filter(event => event.type === 'turn/end').map(event => event.data.reason.kind))
|
||||
.toEqual(['completed'])
|
||||
await expect(access(join(scaffold.workspaceCwd, 'workspace', 'notes.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(await panel.count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/shortcuts-desktop.e2e.ts",
|
||||
"tests/shortcuts-workspace.e2e.ts",
|
||||
"tests/shortcuts-panels.e2e.ts",
|
||||
"tests/shortcuts-fixed.e2e.ts",
|
||||
"tests/shortcuts.e2e.ts",
|
||||
"tests/composer-shortcuts.e2e.ts",
|
||||
"tests/default-product-isolation.e2e.ts",
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-approval/README.md
|
||||
README.md: 1d5a4a56830c34693ee8cc93c6805ec8dd9a8191
|
||||
README.zh.md: f5f932ed59834cd9b805700fa58dd0f552bc8454
|
||||
README.md: ce2ccdbbecef629771d2f4b7e2391668c3c3e9a0
|
||||
README.zh.md: d7a94ae9156c61b8ec8a6e18ce261bedf0ede48d
|
||||
@@ -18,6 +18,8 @@ Browser approval presentation over the Agent-scoped Remote Event waterfall. The
|
||||
|
||||
-----
|
||||
|
||||
Focus the approval detail region to approve with Enter or reject with Escape. The mounted plugin reserves both keys against editable shortcuts. Enter on the focused Reject button retains its native reject action. Input controls and IME candidates keep their own keys. Keyboard and pointer actions share one pending-request lock; a withdrawn or replaced request cannot accept another answer, and an earlier failed answer cannot unlock its replacement.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ kind: "package-reference"
|
||||
|
||||
-----
|
||||
|
||||
聚焦审批详情区域后,Enter 批准,Esc 拒绝。插件挂载期间,这两个按键不能分配给可编辑快捷键。聚焦“拒绝”按钮后,Enter 保留按钮原生拒绝操作。输入控件与输入法候选保留各自的按键。键盘和指针操作共用同一待处理请求锁;已撤销或替换的请求不能再次作答,较早请求的失败也不会解锁替代请求。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"react": "^18.2.0"
|
||||
"react": "^18.2.0",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Composer takeover for one pending approval waterfall. */
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts'
|
||||
import css from './ApprovalPanel.module.css'
|
||||
@@ -23,12 +23,43 @@ function ApprovalFlow({ pending, detail, t }: {
|
||||
t: ApprovalComposerProps['t']
|
||||
}) {
|
||||
const [answered, setAnswered] = useState(false)
|
||||
const waiting = useRef(false)
|
||||
const active = useRef(true)
|
||||
const composing = useRef(false)
|
||||
const compositionEnded = useRef(false)
|
||||
useEffect(() => {
|
||||
active.current = true
|
||||
return () => { active.current = false }
|
||||
}, [])
|
||||
const answer = (outcome: 'allowed-once' | 'rejected'): void => {
|
||||
if (waiting.current || !pending.answerable) return
|
||||
waiting.current = true
|
||||
setAnswered(true)
|
||||
void pending.answer(outcome).catch(() => { setAnswered(false) })
|
||||
void pending.answer(outcome).catch(() => {
|
||||
if (!active.current || !pending.answerable) return
|
||||
waiting.current = false
|
||||
setAnswered(false)
|
||||
})
|
||||
}
|
||||
const keydown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const element = event.target as Element
|
||||
if (event.defaultPrevented || !event.currentTarget.contains(document.activeElement)
|
||||
|| element.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]') !== null) return
|
||||
if (event.key !== 'Enter' && event.key !== 'Escape') return
|
||||
if (event.key === 'Enter' && element.closest('button, a[href], [role="button"]') !== null) return
|
||||
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- IME 229 covers engines without isComposing.
|
||||
if (event.repeat || composing.current || compositionEnded.current || event.nativeEvent.isComposing || event.keyCode === 229) return
|
||||
answer(event.key === 'Enter' ? 'allowed-once' : 'rejected')
|
||||
}
|
||||
return (
|
||||
<div className={css.root} data-approval-key={pending.key} aria-busy={answered}>
|
||||
<div className={css.root} data-approval-key={pending.key} aria-busy={answered}
|
||||
onKeyDown={keydown}
|
||||
onKeyUpCapture={() => { compositionEnded.current = false }}
|
||||
onCompositionStartCapture={() => { composing.current = true }}
|
||||
onCompositionEndCapture={() => { composing.current = false; compositionEnded.current = true }}>
|
||||
<div className={css.card}>
|
||||
<div className={css.strip}><StateDot state={answered ? 'ongoing' : 'warning'} />{t('waiting')}</div>
|
||||
<div
|
||||
|
||||
@@ -118,6 +118,14 @@ export class PendingApproval {
|
||||
if (request.signal.aborted) onAbort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Availability of this pending request after answer or withdrawal.
|
||||
* @returns whether this request can still accept a decision.
|
||||
*/
|
||||
get answerable(): boolean {
|
||||
return !this.#settled
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Host waterfall with the user's decision.
|
||||
* @param outcome - supported interactive decision.
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { PendingInteractionPublisher } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { TypertClientEventListener } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { ApprovalPanel } from './ApprovalPanel.tsx'
|
||||
import { PendingApproval } from './contract/slots.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
@@ -74,6 +75,15 @@ async function answerApproval(
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-approval: dictionaries')
|
||||
ctx.inject(['shortcuts'], (scope) => {
|
||||
const t = ctx.locale.bind(NS)
|
||||
scope.effect(() => scope.shortcuts.registerFixed({
|
||||
id: 'approval.allow' as ShortcutCommandId, label: () => t('allowOnce'), keys: ['Enter'], bindings: [{ code: 'Enter', modifiers: [] }], group: 'approval',
|
||||
}), 'ui-approval: fixed allow reference')
|
||||
scope.effect(() => scope.shortcuts.registerFixed({
|
||||
id: 'approval.reject' as ShortcutCommandId, label: () => t('reject'), keys: ['Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'approval',
|
||||
}), 'ui-approval: fixed reject reference')
|
||||
})
|
||||
const registerPendingInteraction = ctx.uiSession.registerPendingInteraction<PendingApproval>(
|
||||
() => 0,
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
|
||||
import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApprovalPanel } from '../src/client/ApprovalPanel.tsx'
|
||||
import type { ApprovalComposerProps } from '../src/client/contract/slots.ts'
|
||||
@@ -40,7 +40,7 @@ interface PluginBench {
|
||||
}
|
||||
}
|
||||
|
||||
function setupPlugin(): PluginBench {
|
||||
async function setupPlugin(): Promise<PluginBench> {
|
||||
const ctx = new Context()
|
||||
let listener: ApprovalListener | undefined
|
||||
let registration: {
|
||||
@@ -84,7 +84,8 @@ function setupPlugin(): PluginBench {
|
||||
register: vi.fn(() => disposeLocale),
|
||||
} as never)
|
||||
|
||||
apply(ctx)
|
||||
const fiber = ctx.plugin({ apply })
|
||||
await fiber.await()
|
||||
if (listener === undefined) throw new Error('approval listener was not registered')
|
||||
return {
|
||||
ctx,
|
||||
@@ -198,7 +199,7 @@ describe('PendingApproval', () => {
|
||||
|
||||
describe('approval Remote Event consumer', () => {
|
||||
it('delegates an event that has no Agent scope', async () => {
|
||||
const bench = setupPlugin()
|
||||
const bench = await setupPlugin()
|
||||
const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable'))
|
||||
|
||||
await expect(bench.listener.call(bench.ctx, { toolName: 'bash' }, next))
|
||||
@@ -209,7 +210,7 @@ describe('approval Remote Event consumer', () => {
|
||||
})
|
||||
|
||||
it('publishes one scoped takeover, returns the answer, and keeps stable registrations', async () => {
|
||||
const bench = setupPlugin()
|
||||
const bench = await setupPlugin()
|
||||
const scope = createScope(bench.ctx, id('s1'))
|
||||
await scope.fiber.await()
|
||||
const controller = new AbortController()
|
||||
@@ -245,7 +246,7 @@ describe('approval Remote Event consumer', () => {
|
||||
})
|
||||
|
||||
it('propagates request cancellation after removing the pending object', async () => {
|
||||
const bench = setupPlugin()
|
||||
const bench = await setupPlugin()
|
||||
const scope = createScope(bench.ctx, id('s1'))
|
||||
await scope.fiber.await()
|
||||
const controller = new AbortController()
|
||||
@@ -265,7 +266,7 @@ describe('approval Remote Event consumer', () => {
|
||||
})
|
||||
|
||||
it('delegates an active request when its interaction domain unloads', async () => {
|
||||
const bench = setupPlugin()
|
||||
const bench = await setupPlugin()
|
||||
const scope = createScope(bench.ctx, id('s1'))
|
||||
await scope.fiber.await()
|
||||
const next = vi.fn(() => Promise.resolve<'unavailable'>('unavailable'))
|
||||
@@ -281,7 +282,7 @@ describe('approval Remote Event consumer', () => {
|
||||
})
|
||||
|
||||
it('publishes a scoped request without optional request metadata', async () => {
|
||||
const bench = setupPlugin()
|
||||
const bench = await setupPlugin()
|
||||
const scope = createScope(bench.ctx, id('s1'))
|
||||
await scope.fiber.await()
|
||||
const result = bench.listener.call(scope.ctx, { toolName: 'read' }, () => Promise.resolve('unavailable'))
|
||||
@@ -297,7 +298,7 @@ describe('approval Remote Event consumer', () => {
|
||||
})
|
||||
|
||||
it('removes stable registrations with the plugin lifetime', async () => {
|
||||
const bench = setupPlugin()
|
||||
const bench = await setupPlugin()
|
||||
await bench.ctx.fiber.dispose()
|
||||
expect(bench.disposeSlot).toHaveBeenCalledOnce()
|
||||
expect(bench.disposeLocale).toHaveBeenCalledOnce()
|
||||
@@ -361,6 +362,102 @@ describe('ApprovalPanel', () => {
|
||||
await expect(pending.result).resolves.toBe('allowed-once')
|
||||
})
|
||||
|
||||
it.each([['Enter', 'allowed-once'], ['Escape', 'rejected']] as const)(
|
||||
'answers the focused container with %s through the pending request', async (key, outcome) => {
|
||||
const pending = new PendingApproval(id('s1'), { toolName: 'bash' })
|
||||
render(<ApprovalPanel {...panelProps(pending)} />)
|
||||
const group = screen.getByRole('group', { name: 'Approval details' })
|
||||
group.focus()
|
||||
const bubbled = vi.fn()
|
||||
window.addEventListener('keydown', bubbled)
|
||||
try {
|
||||
fireEvent.keyDown(group, { key, code: key })
|
||||
await expect(pending.result).resolves.toBe(outcome)
|
||||
expect(bubbled).not.toHaveBeenCalled()
|
||||
expect(pending.answerable).toBe(false)
|
||||
} finally { window.removeEventListener('keydown', bubbled) }
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves button Enter to its native click and rejects from the reject button', async () => {
|
||||
const pending = new PendingApproval(id('s1'), { toolName: 'bash' })
|
||||
render(<ApprovalPanel {...panelProps(pending)} />)
|
||||
const reject = screen.getByRole('button', { name: 'Reject' })
|
||||
reject.focus()
|
||||
expect(fireEvent.keyDown(reject, { key: 'Enter', code: 'Enter' })).toBe(true)
|
||||
expect(pending.answerable).toBe(true)
|
||||
// jsdom does not synthesize the browser's native button activation.
|
||||
fireEvent.click(reject)
|
||||
await expect(pending.result).resolves.toBe('rejected')
|
||||
})
|
||||
|
||||
it('ignores unowned input, modified keys, repeats and IME candidate keys', async () => {
|
||||
const pending = new PendingApproval(id('s1'), { toolName: 'bash', callId: 'call-1' as ToolCallId })
|
||||
const renderSlot = () => <input aria-label="Approval input" />
|
||||
render(<ApprovalPanel {...panelProps(pending, renderSlot)} />)
|
||||
const group = screen.getByRole('group', { name: 'Approval details' })
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' })
|
||||
expect(pending.answerable).toBe(true)
|
||||
const input = screen.getByRole('textbox', { name: 'Approval input' })
|
||||
input.focus()
|
||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' })
|
||||
fireEvent.keyDown(input, { key: 'Escape', code: 'Escape' })
|
||||
group.focus()
|
||||
expect(fireEvent.keyDown(group, { key: 'Tab', code: 'Tab' })).toBe(true)
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', ctrlKey: true })
|
||||
fireEvent.keyDown(group, { key: 'Escape', code: 'Escape', repeat: true })
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', isComposing: true })
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter', keyCode: 229 })
|
||||
fireEvent.compositionStart(group)
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' })
|
||||
fireEvent.compositionEnd(group)
|
||||
fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' })
|
||||
expect(pending.answerable).toBe(true)
|
||||
fireEvent.keyUp(group, { key: 'Escape', code: 'Escape' })
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' })
|
||||
await expect(pending.result).resolves.toBe('allowed-once')
|
||||
})
|
||||
|
||||
it('locks keyboard and click submissions synchronously while the answer is pending', async () => {
|
||||
const pending = new PendingApproval(id('s1'), { toolName: 'bash' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
const answer = vi.spyOn(pending, 'answer').mockImplementation(() => gate.promise)
|
||||
render(<ApprovalPanel {...panelProps(pending)} />)
|
||||
const group = screen.getByRole('group', { name: 'Approval details' })
|
||||
group.focus()
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' })
|
||||
fireEvent.keyDown(group, { key: 'Escape', code: 'Escape' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reject' }))
|
||||
expect(answer).toHaveBeenCalledExactlyOnceWith('allowed-once')
|
||||
await act(async () => { gate.resolve(undefined); await gate.promise })
|
||||
pending.abort(new Error('test cleanup'))
|
||||
await pending.result.catch(() => {})
|
||||
})
|
||||
|
||||
it('does not settle an aborted request or let its late rejection unlock its replacement', async () => {
|
||||
const first = new PendingApproval(id('s1'), { toolName: 'bash' })
|
||||
const gate = Promise.withResolvers<undefined>()
|
||||
vi.spyOn(first, 'answer').mockImplementation(() => gate.promise)
|
||||
const view = render(<ApprovalPanel {...panelProps(first)} />)
|
||||
let group = screen.getByRole('group', { name: 'Approval details' })
|
||||
group.focus()
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' })
|
||||
first.abort(new Error('request withdrawn'))
|
||||
await first.result.catch(() => {})
|
||||
const second = new PendingApproval(id('s1'), { toolName: 'read' })
|
||||
view.rerender(<ApprovalPanel {...panelProps(second)} />)
|
||||
await act(async () => { gate.reject(new Error('late transport failure')); await gate.promise.catch(() => {}) })
|
||||
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'Reject' }).disabled).toBe(false)
|
||||
second.abort(new Error('request withdrawn'))
|
||||
await second.result.catch(() => {})
|
||||
const answer = vi.spyOn(second, 'answer')
|
||||
group = screen.getByRole('group', { name: 'Approval details' })
|
||||
group.focus()
|
||||
fireEvent.keyDown(group, { key: 'Enter', code: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reject' }))
|
||||
expect(answer).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-enables actions when answering fails', async () => {
|
||||
const pending = new PendingApproval(id('s1'), { toolName: 'bash' })
|
||||
vi.spyOn(pending, 'answer').mockRejectedValue(new Error('transport closed'))
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../shortcuts/tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 3536ea9c4b06100991047399cbc43dbe561d62a9
|
||||
README.zh.md: 1653607e37084a5d754c3c492bc6bda89ecf931d
|
||||
README.md: 3719bac54b7629d0c8ec9efb2c767f361bcd8f61
|
||||
README.zh.md: 7256ffc7b34c464aab2dafc70ec71d8c6db821f6
|
||||
@@ -82,6 +82,8 @@ File chips and editable skill references share a whole-reference hover backgroun
|
||||
|
||||
When another writer owns the Session, the send-error toast asks the user to quit other running DSH instances and retry.
|
||||
|
||||
Two independent Escape presses in the focused Chat or Composer stop its current running turn and preserve queued messages. The interval comes from the shortcuts plugin’s `stopSequenceMs` configuration (500 ms by default). A menu, approval, modal, terminal, embedded webpage, composition, repeated key, changed input region, Session or turn breaks the sequence. The shortcut uses the same scoped cancellation as the Stop button. Its fixed-action registration reserves plain Escape against editable shortcuts.
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## Temporary composer entries
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
|
||||
|
||||
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
|
||||
|
||||
在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。固定操作注册使普通 Esc 不能分配给可编辑快捷键。
|
||||
|
||||
<a id="temporary-composer-entries"></a>
|
||||
## 临时 composer entry
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@
|
||||
"@lexical/plain-text": "^0.49.0",
|
||||
"@lexical/history": "^0.49.0",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^"
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-shortcuts": "workspace:^"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { UiConversation } from './conversation/assembly.ts'
|
||||
import type { ViewTab } from './contract/views.ts'
|
||||
import type {
|
||||
@@ -38,6 +39,7 @@ import { ConversationHeader } from './skeleton/ConversationHeader.tsx'
|
||||
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
|
||||
import { InputBar } from './skeleton/InputBar.tsx'
|
||||
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
|
||||
import { installStopShortcut } from './stop-shortcut.ts'
|
||||
import { DEVELOPER_TOOLS_VIEW_ID, resolveActiveView } from './view-selection.ts'
|
||||
import { en, NS, zh, type ConversationKey } from './locales.ts'
|
||||
import { CONVERSATION_SETTINGS_NAMESPACE, type ConversationSettings } from '../submission-settings.ts'
|
||||
@@ -225,6 +227,19 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
}
|
||||
}, 'ui-conversation: View selection')
|
||||
|
||||
const stop = (sessionId: SessionId): void => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch((_error: unknown) => {
|
||||
// Stop failure is published through Session promptError.
|
||||
})
|
||||
}
|
||||
ctx.inject(['shortcuts'], (scope) => {
|
||||
scope.effect(() => installStopShortcut(scope.shortcuts, sessions, uiConversation, ctx.uiSession, stop),
|
||||
'ui-conversation: fixed stop input')
|
||||
scope.effect(() => scope.shortcuts.registerFixed({
|
||||
id: 'response.stop' as ShortcutCommandId, label: () => t('input.stop'), keys: ['Esc', 'Esc'], bindings: [{ code: 'Escape', modifiers: [] }], group: 'input',
|
||||
}), 'ui-conversation: fixed stop reference')
|
||||
})
|
||||
|
||||
const inputHub = new InputHub(ctx, t)
|
||||
const composerBlocks = new ComposerBlockRegistry()
|
||||
|
||||
@@ -475,11 +490,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
|
||||
span: { ...selection, draftRev: snapshot.draftRev },
|
||||
})
|
||||
},
|
||||
stop: () => {
|
||||
scopedConversation(sessions, sessionId).cancel().catch(() => {
|
||||
// Stop failure is published through Session promptError.
|
||||
})
|
||||
},
|
||||
stop: () => { stop(sessionId) },
|
||||
hooks: {
|
||||
busyEnter: submissionPolicy.busyEnter,
|
||||
fileUploads: conversation.fileUploads,
|
||||
|
||||
@@ -174,7 +174,7 @@ export function ConversationContent(props: ConversationContentProps) {
|
||||
// on the fallback alone would leave a business-owned takeover at the content
|
||||
// end off-screen when the user is not pinned to the floor.
|
||||
const composerSeat = (
|
||||
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="">
|
||||
<div ref={seatResizeRef} className={css.composerSeat} data-composer-seat="" data-conversation-region="composer">
|
||||
{composer}
|
||||
</div>
|
||||
)
|
||||
@@ -184,6 +184,8 @@ export function ConversationContent(props: ConversationContentProps) {
|
||||
ref={setBody}
|
||||
className={clsx(css.body, props.variant === 'embedded' && css.embeddedBody)}
|
||||
data-conversation-content=""
|
||||
data-conversation-session={sessionId}
|
||||
data-conversation-region="chat"
|
||||
data-content-phase={phase}
|
||||
>
|
||||
<div className={css.scrollBody} data-conversation-scroll="">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/** Two independent Escape presses addressed to one Conversation occurrence and live turn. */
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Freshly resolved cancellation target; occurrence and region preserve focus ownership. */
|
||||
export interface StopTarget {
|
||||
readonly sessionId: SessionId
|
||||
readonly turn: number
|
||||
/** Materialized Session binding identity; replacement invalidates an earlier press. */
|
||||
readonly generation: object
|
||||
readonly region: Element
|
||||
readonly cancel: () => void
|
||||
}
|
||||
|
||||
/** One short-lived first press, cleared before an accepted cancellation runs. */
|
||||
export class StopSequence {
|
||||
private first: { target: StopTarget; deadline: number } | undefined
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/**
|
||||
* @param intervalMs - validated maximum time between independent presses.
|
||||
* @param release - releases observations retained by the pending first press.
|
||||
*/
|
||||
constructor(private readonly intervalMs: number, private readonly release: () => void) {}
|
||||
|
||||
/** Clear the first press and its expiry timer. */
|
||||
reset(): void {
|
||||
this.first = undefined
|
||||
clearTimeout(this.timer)
|
||||
this.timer = undefined
|
||||
this.release()
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one eligible, non-repeated Escape against freshly resolved current state.
|
||||
* @param target - live turn and actual focused input region.
|
||||
* @returns whether this press requested cancellation.
|
||||
*/
|
||||
press(target: StopTarget): boolean {
|
||||
const first = this.first
|
||||
this.reset()
|
||||
if (first !== undefined && performance.now() <= first.deadline
|
||||
&& first.target.sessionId === target.sessionId
|
||||
&& first.target.turn === target.turn && first.target.generation === target.generation
|
||||
&& first.target.region === target.region) {
|
||||
target.cancel()
|
||||
return true
|
||||
}
|
||||
this.first = { target, deadline: performance.now() + this.intervalMs }
|
||||
this.timer = setTimeout(() => { this.reset() }, this.intervalMs + 1)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/** Fixed Escape routing into the current Conversation turn's scoped cancellation. */
|
||||
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { Shortcuts } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { UiSession } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { UiConversation } from './conversation/assembly.ts'
|
||||
import { StopSequence } from './stop-sequence.ts'
|
||||
|
||||
/**
|
||||
* Subscribe fixed input to the same Session cancellation used by the stop button.
|
||||
* @param shortcuts - window keyboard arbitration and validated sequence interval.
|
||||
* @param sessions - live Session identities and lifecycle sources.
|
||||
* @param conversation - incremental turn timeline owner.
|
||||
* @param uiSession - current pending-interaction status.
|
||||
* @param cancel - scoped stop operation that preserves Queue and reports failures.
|
||||
* @returns disposer releasing the input subscription, pending watches and expiry timer.
|
||||
*/
|
||||
export function installStopShortcut(
|
||||
shortcuts: Shortcuts,
|
||||
sessions: ISessions,
|
||||
conversation: UiConversation,
|
||||
uiSession: UiSession,
|
||||
cancel: (sessionId: SessionId) => void,
|
||||
): () => void {
|
||||
let unwatch = (): void => {}
|
||||
const sequence = new StopSequence(shortcuts.stopSequenceMs, () => {
|
||||
unwatch()
|
||||
unwatch = () => {}
|
||||
})
|
||||
const reset = (): void => { sequence.reset() }
|
||||
const off = shortcuts.observeFixedInput((input) => {
|
||||
if (input.type === 'reset') { reset(); return }
|
||||
const { gesture, context } = input
|
||||
if (gesture.code !== 'Escape' || gesture.repeat || gesture.composing || gesture.defaultPrevented
|
||||
|| gesture.control || gesture.alt || gesture.shift || gesture.meta || context.modal !== null
|
||||
|| context.region === 'terminal' || context.target === null) { reset(); return }
|
||||
const target = context.target
|
||||
const occurrence = target.closest<HTMLElement>('[data-conversation-session]')
|
||||
const region = target.closest('[data-conversation-region]')
|
||||
if (occurrence === null || region === null || !occurrence.contains(region)
|
||||
|| target.closest('[data-approval-key], iframe, .xterm, [inert]') !== null) { reset(); return }
|
||||
const sessionId = occurrence.dataset.conversationSession as SessionId
|
||||
const binding = sessions.binding(sessionId)
|
||||
if (binding === undefined) { reset(); return }
|
||||
const timeline = conversation.binding(binding).timeline
|
||||
const currentTurn = (): number | undefined => {
|
||||
const session = binding.session.getSnapshot()
|
||||
if (!session.running || session.removed
|
||||
|| (session.subagent !== null && session.subagent.address.mode !== 'continuable')
|
||||
|| uiSession.sessionStatus.getSnapshot().get(sessionId)?.pendingInteraction !== undefined) return undefined
|
||||
const snapshot = timeline.getSnapshot()
|
||||
const latest = snapshot.turnOrder.at(-1)
|
||||
const turn = latest === undefined ? undefined : snapshot.turns.get(latest)
|
||||
return turn?.status === 'open' && turn.start !== undefined ? turn.turn : undefined
|
||||
}
|
||||
const turn = currentTurn()
|
||||
if (turn === undefined) { reset(); return }
|
||||
input.consume()
|
||||
const stopped = sequence.press({ sessionId, turn, generation: binding, region, cancel: () => { cancel(sessionId) } })
|
||||
if (stopped) return
|
||||
const changed = (): void => {
|
||||
if (sessions.binding(sessionId) !== binding || currentTurn() !== turn) reset()
|
||||
}
|
||||
const disposers = [timeline.subscribe(changed), binding.session.subscribe(changed), uiSession.sessionStatus.subscribe(changed)]
|
||||
unwatch = () => { for (const dispose of disposers) dispose() }
|
||||
})
|
||||
return () => { off(); reset() }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { StopSequence, type StopTarget } from '../src/client/stop-sequence.ts'
|
||||
|
||||
const id = (value: string): SessionId => value as SessionId
|
||||
const sequences: StopSequence[] = []
|
||||
function setup(interval = 500) {
|
||||
vi.useFakeTimers()
|
||||
const sequence = new StopSequence(interval, () => {})
|
||||
sequences.push(sequence)
|
||||
const cancel = vi.fn()
|
||||
const target: StopTarget = { sessionId: id('s1'), turn: 1, generation: {}, region: document.createElement('div'), cancel }
|
||||
return { sequence, target, cancel }
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const sequence of sequences.splice(0)) sequence.reset()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('double Escape stop sequence', () => {
|
||||
it('requires two independent presses inside the configured interval and clears before cancelling', () => {
|
||||
const { sequence, target, cancel } = setup()
|
||||
expect(sequence.press(target)).toBe(false)
|
||||
vi.advanceTimersByTime(499)
|
||||
expect(sequence.press(target)).toBe(true)
|
||||
expect(cancel).toHaveBeenCalledOnce()
|
||||
expect(sequence.press(target)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts the inclusive interval endpoint', () => {
|
||||
const { sequence, target } = setup()
|
||||
sequence.press(target)
|
||||
vi.advanceTimersByTime(500)
|
||||
expect(sequence.press(target)).toBe(true)
|
||||
})
|
||||
|
||||
it('expires the first press and respects a deployment-configured interval', () => {
|
||||
const { sequence, target, cancel } = setup(100)
|
||||
sequence.press(target)
|
||||
vi.advanceTimersByTime(101)
|
||||
expect(sequence.press(target)).toBe(false)
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(sequence.press(target)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['session', 'turn', 'region', 'generation'] as const)('does not combine presses across %s changes', (field) => {
|
||||
const { sequence, target, cancel } = setup()
|
||||
sequence.press(target)
|
||||
const next: StopTarget = {
|
||||
...target,
|
||||
...(field === 'session' ? { sessionId: id('s2') }
|
||||
: field === 'turn' ? { turn: 2 }
|
||||
: field === 'generation' ? { generation: {} }
|
||||
: { region: document.createElement('div') }),
|
||||
}
|
||||
expect(sequence.press(next)).toBe(false)
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
expect(sequence.press(next)).toBe(true)
|
||||
})
|
||||
|
||||
it('discards a first press when local input, lifecycle or focus invalidates it', () => {
|
||||
const { sequence, target, cancel } = setup()
|
||||
sequence.press(target)
|
||||
sequence.reset()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(sequence.press(target)).toBe(false)
|
||||
expect(cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { Shortcuts } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { UiConversation } from '../src/client/conversation/assembly.ts'
|
||||
import { ComposerBlockRegistry } from '../src/client/input/blocks.ts'
|
||||
import { InputHub } from '../src/client/input/hub.ts'
|
||||
import { ConversationController } from '../src/client/service.ts'
|
||||
import { installStopShortcut } from '../src/client/stop-shortcut.ts'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
const disposers: (() => void | Promise<void>)[] = []
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposers.splice(0).reverse()) await dispose()
|
||||
document.body.replaceChildren()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function bench() {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
disposers.push(() => runtime.dispose())
|
||||
const cancel = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } }))
|
||||
await runtime.sessions.add({ id: 's1', snapshot: { running: true }, session: { cancel }, events: [
|
||||
{ type: 'event', event: { type: 'turn/start', seq: SessionSeq(1), time: 1, data: { turn: 1 } } },
|
||||
] })
|
||||
const reference = runtime.sessions.retain('s1' as SessionId)
|
||||
await reference.ready
|
||||
const hub = new InputHub(runtime.ctx, makeTranslate(zh, {}))
|
||||
const fiber = runtime.ctx.plugin(ConversationController, {
|
||||
input: hub, blocks: new ComposerBlockRegistry(), maxConcurrentFileUploads: 2,
|
||||
})
|
||||
await fiber.await()
|
||||
const conversation = new UiConversation(runtime.ctx, runtime.sessions)
|
||||
let listener: Parameters<Shortcuts['observeFixedInput']>[0] | undefined
|
||||
const shortcuts = {
|
||||
stopSequenceMs: 500,
|
||||
observeFixedInput: (callback: NonNullable<typeof listener>) => {
|
||||
listener = callback
|
||||
return () => { listener = undefined }
|
||||
},
|
||||
} as Shortcuts
|
||||
const stop = (sessionId: SessionId): void => {
|
||||
const scoped = runtime.sessions.scope(sessionId)!.get('conversation') as ConversationController
|
||||
void scoped.cancel()
|
||||
}
|
||||
const dispose = installStopShortcut(shortcuts, runtime.sessions, conversation, runtime.ctx.uiSession, stop)
|
||||
disposers.push(dispose)
|
||||
const root = document.createElement('div')
|
||||
root.dataset.conversationSession = 's1'
|
||||
root.dataset.conversationRegion = 'chat'
|
||||
document.body.append(root)
|
||||
const input = document.createElement('textarea')
|
||||
input.dataset.conversationRegion = 'composer'
|
||||
root.append(input)
|
||||
input.focus()
|
||||
const press = (overrides: Partial<Parameters<Shortcuts['dispatch']>[0]> = {}, target: Element = input,
|
||||
context: Partial<Parameters<Shortcuts['dispatch']>[1]> = {}) => {
|
||||
const consume = vi.fn()
|
||||
listener?.({ type: 'keydown', gesture: { code: 'Escape', control: false, alt: false, shift: false,
|
||||
meta: false, repeat: false, composing: false, defaultPrevented: false, ...overrides },
|
||||
context: { region: 'editable', modal: null, target, ...context }, consume })
|
||||
return consume
|
||||
}
|
||||
return { runtime, cancel, input, root, press, dispose,
|
||||
reset: () => listener?.({ type: 'reset' }),
|
||||
events: reference.binding.eventSource as MutableSessionEventSource }
|
||||
}
|
||||
|
||||
describe('fixed stop routing', () => {
|
||||
it('cancels through the scoped ConversationController only after two eligible Escapes', async () => {
|
||||
const b = await bench()
|
||||
expect(b.press()).toHaveBeenCalledOnce()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
await Promise.resolve()
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['repeat', 'composing', 'defaultPrevented', 'control', 'alt', 'shift', 'meta'] as const)(
|
||||
'clears the sequence when %s owns a key', async (field) => {
|
||||
const b = await bench()
|
||||
b.press()
|
||||
expect(b.press({ [field]: true })).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
|
||||
it('clears on other keys, local consumption, modal ownership and leaving Conversation', async () => {
|
||||
const b = await bench()
|
||||
for (const invalidate of [
|
||||
() => b.press({ code: 'KeyA' }), () => b.reset(),
|
||||
() => b.press({}, b.input, { modal: 'settings' }),
|
||||
() => b.press({}, document.body),
|
||||
() => b.press({}, b.input, { region: 'terminal' }),
|
||||
]) {
|
||||
b.press()
|
||||
invalidate()
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
b.reset()
|
||||
}
|
||||
})
|
||||
|
||||
it('never combines a press with the next turn even while running remains true', async () => {
|
||||
const b = await bench()
|
||||
b.press()
|
||||
b.events.append({ type: 'event', event: { type: 'turn/end', seq: SessionSeq(2), time: 2,
|
||||
data: { turn: 1, reason: { kind: 'completed' } } } })
|
||||
b.events.append({ type: 'event', event: { type: 'turn/start', seq: SessionSeq(3), time: 3, data: { turn: 2 } } })
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears when an approval appears and disappears between presses', async () => {
|
||||
const b = await bench()
|
||||
const publish = b.runtime.ctx.uiSession.registerPendingInteraction<SessionPendingInteractionBase>(() => 0)
|
||||
b.press()
|
||||
const remove = publish({ kind: 'approval', key: 'request-a', sessionId: 's1' as SessionId }, async () => {})
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
remove()
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
const removeSecond = publish({ kind: 'approval', key: 'request-b', sessionId: 's1' as SessionId }, async () => {})
|
||||
removeSecond()
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('excludes terminal, iframe, approval and inert descendants even inside Conversation', async () => {
|
||||
const b = await bench()
|
||||
for (const [tag, attribute] of [['div', 'class'], ['iframe', ''], ['div', 'data-approval-key'], ['div', 'inert']]) {
|
||||
const element = document.createElement(tag!)
|
||||
if (attribute !== '') element.setAttribute(attribute!, attribute === 'class' ? 'xterm' : '')
|
||||
b.root.append(element)
|
||||
b.press()
|
||||
expect(b.press({}, element)).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
b.reset()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears on running and removed lifecycle changes', async () => {
|
||||
const b = await bench()
|
||||
b.press()
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = false })
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.running = true })
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => { draft.removed = true })
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('requires a live binding and observed turn start before arming', async () => {
|
||||
const b = await bench()
|
||||
b.root.dataset.conversationSession = 'missing'
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
b.root.dataset.conversationSession = 's1'
|
||||
b.events.replace([], false)
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the first press across step updates in the same running turn', async () => {
|
||||
const b = await bench()
|
||||
b.press()
|
||||
b.events.append({ type: 'event', event: { type: 'step/start', seq: SessionSeq(2), time: 2,
|
||||
data: { turn: 1, step: 1 } } })
|
||||
b.press()
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the existing stop path for continuable children and excludes one-shot children', async () => {
|
||||
const b = await bench()
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
|
||||
draft.subagent = { address: { parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: 's1' as SessionId, mode: 'one-shot' } }
|
||||
})
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
await b.runtime.sessions.updateSessionSnapshot('s1', (draft) => {
|
||||
draft.subagent = { parentAvailable: false, address: { parentSessionId: 'parent' as SessionId,
|
||||
childSessionId: 's1' as SessionId, mode: 'continuable' } }
|
||||
})
|
||||
b.press()
|
||||
b.press()
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not arm without a cancellable live turn or after disposal', async () => {
|
||||
const b = await bench()
|
||||
b.events.append({ type: 'event', event: { type: 'turn/end', seq: SessionSeq(2), time: 2,
|
||||
data: { turn: 1, reason: { kind: 'completed' } } } })
|
||||
expect(b.press()).not.toHaveBeenCalled()
|
||||
b.press()
|
||||
b.dispose()
|
||||
b.press()
|
||||
expect(b.cancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -98,6 +98,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../context/file-reference"
|
||||
},
|
||||
{
|
||||
"path": "../shortcuts/tsconfig.client.json"
|
||||
}
|
||||
],
|
||||
"exclude": [
|
||||
|
||||
Generated
+6
@@ -2942,6 +2942,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:^
|
||||
version: link:../shortcuts
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
@@ -3265,6 +3268,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-shortcuts':
|
||||
specifier: workspace:^
|
||||
version: link:../shortcuts
|
||||
'@deepseek-ai/dsh-client-store':
|
||||
specifier: workspace:^
|
||||
version: link:../store
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"apps/web/tests/shortcuts-desktop.e2e.ts",
|
||||
"apps/web/tests/shortcuts-workspace.e2e.ts",
|
||||
"apps/web/tests/shortcuts-panels.e2e.ts",
|
||||
"apps/web/tests/shortcuts-fixed.e2e.ts",
|
||||
"apps/web/tests/default-product-isolation.e2e.ts",
|
||||
"apps/web/tests/diff-context.e2e.ts",
|
||||
"apps/web/tests/scaffold.ts",
|
||||
|
||||
Reference in new issue
Block a user