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
(
() => 0,
)
diff --git a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx
index 7ae0dbc11a..6e18ace13c 100644
--- a/packages/client/ui-approval/tests/ui-approval.client.spec.tsx
+++ b/packages/client/ui-approval/tests/ui-approval.client.spec.tsx
@@ -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 {
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()
+ 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()
+ 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 = () =>
+ render()
+ 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()
+ const answer = vi.spyOn(pending, 'answer').mockImplementation(() => gate.promise)
+ render()
+ 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()
+ vi.spyOn(first, 'answer').mockImplementation(() => gate.promise)
+ const view = render()
+ 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()
+ await act(async () => { gate.reject(new Error('late transport failure')); await gate.promise.catch(() => {}) })
+ expect(screen.getByRole('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'))
diff --git a/packages/client/ui-approval/tsconfig.json b/packages/client/ui-approval/tsconfig.json
index 6c10dfc080..5bad85d9c5 100644
--- a/packages/client/ui-approval/tsconfig.json
+++ b/packages/client/ui-approval/tsconfig.json
@@ -40,6 +40,9 @@
},
{
"path": "../ui-slots"
+ },
+ {
+ "path": "../shortcuts/tsconfig.client.json"
}
]
}
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index ed2eed97d7..9d705fde70 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -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
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index dfd85b62e1..bfe5ae6f67 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -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.
+
## Temporary composer entries
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 4e4c0c38a8..e71854a3b6 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -82,6 +82,8 @@ Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切
当会话被其他写句柄占用时,发送失败的 toast 提示用户退出其他正在运行的 DSH 后重试。
+在获得焦点的 Chat 或 Composer 中连续独立按下两次 Esc,可停止当前运行轮次并保留排队消息。间隔由 shortcuts 插件的 `stopSequenceMs` 配置决定,默认 500 ms。菜单、审批、模态层、终端、内嵌网页、输入法、重复按键,以及输入区域、Session 或轮次变化会清空序列。快捷键与 Stop 按钮调用同一作用域取消操作。固定操作注册使普通 Esc 不能分配给可编辑快捷键。
+
## 临时 composer entry
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index e195ce4858..a9717a1222 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -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",
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index f4a5ff4780..b1c364f62f 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -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,
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
index adace1c113..23c1c3042d 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx
@@ -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 = (
-
+
{composer}
)
@@ -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}
>
diff --git a/packages/client/ui-conversation/src/client/stop-sequence.ts b/packages/client/ui-conversation/src/client/stop-sequence.ts
new file mode 100644
index 0000000000..bf51f4776e
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/stop-sequence.ts
@@ -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
| 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
+ }
+}
diff --git a/packages/client/ui-conversation/src/client/stop-shortcut.ts b/packages/client/ui-conversation/src/client/stop-shortcut.ts
new file mode 100644
index 0000000000..0a93709033
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/stop-shortcut.ts
@@ -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('[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() }
+}
diff --git a/packages/client/ui-conversation/tests/stop-sequence.client.spec.ts b/packages/client/ui-conversation/tests/stop-sequence.client.spec.ts
new file mode 100644
index 0000000000..94bbb23916
--- /dev/null
+++ b/packages/client/ui-conversation/tests/stop-sequence.client.spec.ts
@@ -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()
+ })
+})
diff --git a/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts b/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts
new file mode 100644
index 0000000000..21dc91bf42
--- /dev/null
+++ b/packages/client/ui-conversation/tests/stop-shortcut.client.spec.ts
@@ -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)[] = []
+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[0] | undefined
+ const shortcuts = {
+ stopSequenceMs: 500,
+ observeFixedInput: (callback: NonNullable) => {
+ 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[0]> = {}, target: Element = input,
+ context: Partial[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(() => 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()
+ })
+})
diff --git a/packages/client/ui-conversation/tsconfig.client.json b/packages/client/ui-conversation/tsconfig.client.json
index 3a99a96437..09831f89e9 100644
--- a/packages/client/ui-conversation/tsconfig.client.json
+++ b/packages/client/ui-conversation/tsconfig.client.json
@@ -98,6 +98,9 @@
},
{
"path": "../../context/file-reference"
+ },
+ {
+ "path": "../shortcuts/tsconfig.client.json"
}
],
"exclude": [
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 727d68293a..9b7dcf5a05 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
diff --git a/tsconfig.host.json b/tsconfig.host.json
index d24ac0ebe3..38cd79d6af 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -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",