diff --git a/cmd/workbenchjourney/main.go b/cmd/workbenchjourney/main.go index dca68c39..dec619a2 100644 --- a/cmd/workbenchjourney/main.go +++ b/cmd/workbenchjourney/main.go @@ -193,9 +193,9 @@ func serveModel(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } - if block.Type == "journey_browser_hold" { - // Keep the actual run active after its completed browser tools while the - // operator captures the production card and stops the browser through UI. + if block.Type == "journey_browser_hold" || block.Type == "journey_stop_hold" { + // Keep the actual run active so journeys can exercise production stopping, + // including after completed browser tools have rendered their cards. // Global teardown cancels the request; the provider timeout is also bounded. <-r.Context().Done() return @@ -278,6 +278,9 @@ func nextResponse(request modelRequest) (contentBlock, error) { if strings.Contains(history, "JOURNEY_BROWSER:") { return nextBrowserJourneyResponse(request, results, tool, text) } + if strings.Contains(history, "JOURNEY_STOP:") { + return contentBlock{Type: "journey_stop_hold"}, nil + } if strings.Contains(history, "JOURNEY_CONTEXT:") { if strings.Contains(history, "JOURNEY_PIN_QUESTION_SHOWN") { return rootReply("已收到答复,继续验证本次任务上下文。") diff --git a/docs/evidence/composer-stop-shortcut/README.md b/docs/evidence/composer-stop-shortcut/README.md new file mode 100644 index 00000000..8c320883 --- /dev/null +++ b/docs/evidence/composer-stop-shortcut/README.md @@ -0,0 +1,43 @@ +# Escape to stop from the message input + +Captured on Windows in Chrome at 1440 × 1000 with the built production UI, +production API and SQLite store. The offline journey provider waits on its real +request context when the submitted message contains `JOURNEY_STOP:`. The UI +therefore cancels a real running turn; interrupt and execution responses were +not mocked, and the page was not edited to produce the screenshots. + +- Before UI: `333a4451`, digest + `c7801ca8cbe28986fd1b513fdd3a9010096e928b2b80f8d527a11f2741a9f22b`. +- After UI: this change on `e790ec4b`, digest + `0c787bae41a07b05786f5d23d8e0f55addb987ec8a40084a1fcbbd13d0bebafa`. + +| Image | Observed behavior | +| --- | --- | +| [Before](before-escape.png) | Escape in the focused Markdown input leaves the turn running. A subsequent execution GET confirmed `state: running`. | +| [Shortcut hint](after-shortcut-hint.png) | The existing stop button shows Esc. Escape in the attachment menu closed that menu and the execution GET still returned `running`. | +| [After Escape](after-escape-stopped.png) | Escape in the message input stops the turn. The stopped notice is visible and the unsent draft is unchanged. | + +The final Markdown check sent one real `POST /threads/{thread_id}/interrupt` +for execution `thread-execution-20261021195553-fc4c0222ada7`. It used the existing +operation key `v2-thread-stop-thread-execution-20261021195553-fc4c0222ada7` and +received HTTP 202. The following execution GET returned `state: idle` and +`last_turn_interrupted: true`. The draft remained +`停止后继续修改的草稿,不要清除。`. + +The same workflow also passed in the plain-text input. Escape in the model menu +closed the menu; Escape on a toolbar button left the execution running. Escape +in the input then received HTTP 202, reached the same confirmed interrupted +state, and retained `纯文本停止后保留的草稿。`. Each cancelled turn's pending +submission returned the expected HTTP 499; Chrome logged those two resource +responses and no other console errors or warnings in the final run. + +Focused React tests passed: 94 tests across composer shortcuts, composer, +conversation, and execution controls. They cover composition, modifier and +repeat keys, native editor Escape handling, overlay priority, absent execution +permission, and pending/stopping deduplication. `npm run build`, the tagged +journey build, `git diff --check`, and the protocol registry check against +`e790ec4b7856a5eafea4d2ea7d441f05ec677963` passed. The production build retained +the existing large-chunk warning. + +The journey provider branch is excluded without the `workbenchjourney` build +tag. Its normal response paths and provider timeout configuration are unchanged. diff --git a/docs/evidence/composer-stop-shortcut/after-escape-stopped.png b/docs/evidence/composer-stop-shortcut/after-escape-stopped.png new file mode 100644 index 00000000..e59f6c46 Binary files /dev/null and b/docs/evidence/composer-stop-shortcut/after-escape-stopped.png differ diff --git a/docs/evidence/composer-stop-shortcut/after-shortcut-hint.png b/docs/evidence/composer-stop-shortcut/after-shortcut-hint.png new file mode 100644 index 00000000..dba1a79c Binary files /dev/null and b/docs/evidence/composer-stop-shortcut/after-shortcut-hint.png differ diff --git a/docs/evidence/composer-stop-shortcut/before-escape.png b/docs/evidence/composer-stop-shortcut/before-escape.png new file mode 100644 index 00000000..e37ac618 Binary files /dev/null and b/docs/evidence/composer-stop-shortcut/before-escape.png differ diff --git a/web/src/v2/components/composer-stop-shortcut.test.tsx b/web/src/v2/components/composer-stop-shortcut.test.tsx new file mode 100644 index 00000000..2bc4e4b1 --- /dev/null +++ b/web/src/v2/components/composer-stop-shortcut.test.tsx @@ -0,0 +1,130 @@ +import type { ComponentProps } from "react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { APIClient } from "../../api/client"; +import { LocaleProvider } from "../../lib/locale"; +import { V2Composer } from "./composer"; + +afterEach(() => { cleanup(); localStorage.clear(); }); + +function setup(plainText = true, overrides: Partial> = {}) { + localStorage.setItem("prayu.locale.v1", "zh-CN"); + localStorage.setItem("universal-code.composer-plain-text", String(plainText)); + const onStop = vi.fn(() => true); + const onSubmit = vi.fn(async () => undefined); + const onDraftChange = vi.fn(); + const client = { hasThreadControl: true } as APIClient; + render( + + ); + return { onStop, onSubmit, onDraftChange, input: screen.getByRole("textbox", { name: "继续对话" }) }; +} + +it.each([true, false])("stops from the message input without submitting or clearing its draft (plain=%s)", (plainText) => { + const { input, onStop, onSubmit, onDraftChange } = setup(plainText); + act(() => input.focus()); + const escape = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + fireEvent(input, escape); + expect(onStop).toHaveBeenCalledOnce(); + expect(escape.defaultPrevented).toBe(true); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onDraftChange).not.toHaveBeenCalled(); + if (plainText) expect(input).toHaveValue("尚未提交的草稿"); + else expect(input).toHaveTextContent("尚未提交的草稿"); +}); + +it("leaves composing, modified, repeated and previously handled Escape events alone", () => { + const { input, onStop } = setup(); + for (const options of [{ isComposing: true }, { keyCode: 229 }, { repeat: true }, + { altKey: true }, { ctrlKey: true }, { metaKey: true }, { shiftKey: true }]) { + const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true, ...options }); + fireEvent(input, event); + expect(event.defaultPrevented).toBe(false); + } + const handled = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + handled.preventDefault(); + fireEvent(input, handled); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).not.toHaveBeenCalled(); + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).toHaveBeenCalledOnce(); +}); + +it("takes precedence over a native message-editor Escape binding only when stopping is available", () => { + const onStop = vi.fn(() => false); + const { input } = setup(false, { onStop }); + // Tiptap consumes Escape to select a parent node in a real browser. + const editorEscape = vi.fn((event: Event) => event.preventDefault()); + input.addEventListener("keydown", editorEscape); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).toHaveBeenCalledOnce(); + expect(editorEscape).toHaveBeenCalledOnce(); + onStop.mockReturnValue(true); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).toHaveBeenCalledTimes(2); + expect(editorEscape).toHaveBeenCalledOnce(); +}); + +it("does not take Escape from toolbar controls, other editors, or an unavailable stop action", () => { + const unavailable = vi.fn(() => false); + const { input } = setup(true, { onStop: unavailable }); + fireEvent.keyDown(screen.getByRole("button", { name: "添加附件" }), { key: "Escape" }); + fireEvent.keyDown(document.body, { key: "Escape" }); + const otherEditor = document.createElement("textarea"); + document.body.append(otherEditor); + try { fireEvent.keyDown(otherEditor, { key: "Escape" }); } finally { otherEditor.remove(); } + expect(unavailable).not.toHaveBeenCalled(); + const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }); + fireEvent(input, event); + expect(event.defaultPrevented).toBe(false); + expect(unavailable).toHaveBeenCalledOnce(); +}); + +it("keeps disabled input from stopping a run", () => { + const { input, onStop } = setup(true, { disabled: true }); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).not.toHaveBeenCalled(); +}); + +it("closes the attachment menu and project file dialog without stopping", async () => { + const client = { hasThreadControl: true, hasEvidenceAttachment: true, + workspaceExplore: vi.fn().mockRejectedValue(new Error("Workspace is unavailable")) } as unknown as APIClient; + const { input, onStop } = setup(true, { client }); + const user = userEvent.setup(); + const add = screen.getByRole("button", { name: "添加附件" }); + await user.click(add); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(onStop).not.toHaveBeenCalled(); + await user.click(add); + await user.click(screen.getByRole("menuitem", { name: "引用项目文件" })); + expect(await screen.findByRole("dialog", { name: "选择项目文件" })).toBeInTheDocument(); + // Even a stale input target must leave an open dialog's Escape to its owner. + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(add).toHaveFocus(); + expect(input).toHaveValue("尚未提交的草稿"); +}); + +it("leaves model selection Escape to the menu before allowing a later input Escape", async () => { + const client = { hasThreadControl: true, hasModelControl: true, + threadModelRoute: vi.fn().mockResolvedValue({ provider: "local", model: "model", source: "default" }), + availableModelRoutes: vi.fn().mockResolvedValue({ generation: 1, routes: [] }) } as unknown as APIClient; + const { input, onStop } = setup(true, { client, onManageModels: vi.fn() }); + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: /模型路由,当前 local · model/u })); + expect(screen.getByRole("menu", { name: "模型与响应设置" })).toBeInTheDocument(); + fireEvent.keyDown(input, { key: "Escape" }); + expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + expect(onStop).not.toHaveBeenCalled(); + act(() => input.focus()); + fireEvent.keyDown(input, { key: "Escape" }); + expect(onStop).toHaveBeenCalledOnce(); +}); diff --git a/web/src/v2/components/composer.tsx b/web/src/v2/components/composer.tsx index fe58f108..360b18ed 100644 --- a/web/src/v2/components/composer.tsx +++ b/web/src/v2/components/composer.tsx @@ -34,7 +34,7 @@ export type V2ComposerSubmitResult = void | typeof v2ComposerNotSubmitted; export function V2Composer({ client, threadID, workspaceID, runID, workspaces, disabled = false, submitDisabled = false, projectID, allowProjectless = false, placeholder, newThreadControls, threadControls, runActive = false, onManageModels, - pendingModelRoute, onPendingModelRouteChange, onWorkspaceChange, onSubmit, draft, onDraftChange, + pendingModelRoute, onPendingModelRouteChange, onWorkspaceChange, onSubmit, onStop, draft, onDraftChange, fileReferenceUnavailableReason, confirmedSubmission, presentedSubmissionErrors = [] }: { client: APIClient; threadID: string; @@ -54,6 +54,7 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d onPendingModelRouteChange?: (route: V2PendingModelRoute | null) => void; onWorkspaceChange: (workspaceID: string) => void; onSubmit: (content: string, files?: V2FileReference[], images?: WorkspaceImageAttachment[], draftVersion?: V2DraftVersion, attachments?: WorkspaceFileAttachment[], mcpReferences?: MCPDraftReference[]) => Promise; + onStop?: () => boolean; draft?: string; onDraftChange?: (content: string, expected?: string) => void; fileReferenceUnavailableReason?: string; @@ -99,6 +100,7 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d const nativeClipboard = useNativeClipboard({ workspaceID, threadID, disabled: attachmentsDisabled, captureImport: captureNativeImport, onImported: (incomingImages, incomingFiles) => captureNativeImport()(incomingImages, incomingFiles) }); const pasteGesture = useRef<{ handled: boolean; plain: boolean } | null>(null); + const composing = useRef(false); const attachmentPicker = useRef(null); const addTriggerRef = useRef(null); const [referencePickerOpen, setReferencePickerOpen] = useState(false); @@ -299,6 +301,18 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d }, 0); } }; + const stopFromInput = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented || event.repeat || disabled || !onStop || + event.altKey || event.ctrlKey || event.metaKey || event.shiftKey || composing.current || + event.nativeEvent.isComposing || event.keyCode === 229) return; + const target = event.target; + // React portals also propagate through this form. Only the message editor + // may interrupt; menus and dialogs retain their own Escape behavior. + if (!(target instanceof Element) || !event.currentTarget.contains(target) || target.closest("[inert]") || + !target.closest('textarea, .v2-markdown-input[contenteditable="true"]') || + document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]')) return; + if (onStop()) { event.preventDefault(); event.stopPropagation(); } + }; const toggleEditorMode = (trigger: HTMLElement | null) => { const next = !plainText; @@ -339,7 +353,12 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d icon: