Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions cmd/workbenchjourney/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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("已收到答复,继续验证本次任务上下文。")
Expand Down
43 changes: 43 additions & 0 deletions docs/evidence/composer-stop-shortcut/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
130 changes: 130 additions & 0 deletions web/src/v2/components/composer-stop-shortcut.test.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentProps<typeof V2Composer>> = {}) {
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(<LocaleProvider><QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<V2Composer client={client} threadID="thread-stop" workspaceID="workspace-stop" workspaces={[]}
onWorkspaceChange={vi.fn()} draft="尚未提交的草稿" onDraftChange={onDraftChange}
onSubmit={onSubmit} onStop={onStop} {...overrides} />
</QueryClientProvider></LocaleProvider>);
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();
});
23 changes: 21 additions & 2 deletions web/src/v2/components/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<V2ComposerSubmitResult>;
onStop?: () => boolean;
draft?: string;
onDraftChange?: (content: string, expected?: string) => void;
fileReferenceUnavailableReason?: string;
Expand Down Expand Up @@ -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<HTMLInputElement>(null);
const addTriggerRef = useRef<HTMLButtonElement>(null);
const [referencePickerOpen, setReferencePickerOpen] = useState(false);
Expand Down Expand Up @@ -299,6 +301,18 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d
}, 0);
}
};
const stopFromInput = (event: KeyboardEvent<HTMLFormElement>) => {
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;
Expand Down Expand Up @@ -339,7 +353,12 @@ export function V2Composer({ client, threadID, workspaceID, runID, workspaces, d
icon: <SquarePen aria-hidden="true" size={18} />, disabled, onSelect: () => toggleEditorMode(addTriggerRef.current) });

return <form className="v2-composer" data-empty={!hasUnsentDraft} data-editor-expanded={editorExpanded}
onSubmit={(event) => void submit(event)} onKeyDownCapture={onKeyDown} onPasteCapture={(event) => {
onSubmit={(event) => void submit(event)} onKeyDownCapture={(event) => {
// A running turn takes precedence over Tiptap's native Escape binding.
stopFromInput(event); onKeyDown(event);
}}
onCompositionStartCapture={() => { composing.current = true; }}
onCompositionEndCapture={() => { composing.current = false; }} onPasteCapture={(event) => {
if (pasteGesture.current?.plain) return;
const files = clipboardFiles(event.clipboardData);
if (files.length) {
Expand Down
45 changes: 41 additions & 4 deletions web/src/v2/components/conversation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as desktopBridge from "../../lib/desktop-bridge";
import { V2Conversation, V2FileDrawer, V2TerminalDrawer, parseProjectFileLink } from "./conversation";

const composerFiles = vi.hoisted(() => ({ current: undefined as V2FileReference[] | undefined }));
const composerStop = vi.hoisted(() => ({ current: undefined as (() => boolean) | undefined }));
const streams = vi.hoisted(() => ({ events: "live", model: "waiting" }));

vi.mock("../../hooks/use-run-event-stream", () => ({
Expand Down Expand Up @@ -43,15 +44,16 @@ vi.mock("../projection/narrative", () => ({
}));

vi.mock("./composer", () => ({
V2Composer: ({ disabled, onSubmit, threadID, fileReferenceUnavailableReason }: {
V2Composer: ({ disabled, onSubmit, onStop, threadID, fileReferenceUnavailableReason }: {
disabled: boolean;
onSubmit: (content: string, files?: V2FileReference[]) => Promise<void>;
onStop?: () => boolean;
threadID: string;
fileReferenceUnavailableReason?: string;
}) => <button data-file-reference-unavailable={fileReferenceUnavailableReason}
}) => { composerStop.current = onStop; return <button data-file-reference-unavailable={fileReferenceUnavailableReason}
disabled={disabled} onClick={() => void onSubmit(`pending-${threadID}`, composerFiles.current).catch(() => undefined)} type="button">
发送 {threadID}
</button>,
</button>; },
}));

vi.mock("../../components/user-terminal-panel", () => ({
Expand All @@ -64,7 +66,7 @@ vi.mock("../../components/user-terminal-panel", () => ({
</div>,
}));

afterEach(() => { cleanup(); composerFiles.current = undefined; streams.events = "live"; streams.model = "waiting"; });
afterEach(() => { cleanup(); composerFiles.current = undefined; composerStop.current = undefined; streams.events = "live"; streams.model = "waiting"; });

const workspaces = [{ id: "workspace-1", name: "Workspace 1" }] as WorkspaceView[];

Expand Down Expand Up @@ -639,6 +641,41 @@ describe("V2Conversation", () => {
expect(screen.queryByText("正在工作")).not.toBeInTheDocument();
});

it("routes the composer stop action through the existing authorized button and pending state", async () => {
const execution: ThreadExecutionView = { version: "thread_execution.v1", thread_id: "thread-a",
execution_id: "execution-a", state: "running", queued_messages: 0, capability_grant: false };
const pending = deferred<ThreadExecutionView>();
const interruptThread = vi.fn(() => pending.promise);
const threadExecution = vi.fn().mockResolvedValue(execution);
const view = renderConversation(baseClient({ hasRunExecution: true, hasThreadExecutionRead: true,
threadExecution, interruptThread }));
const button = await screen.findByRole("button", { name: "停止当前执行" });
expect(button).toHaveAttribute("aria-keyshortcuts", "Escape");
expect(button).toHaveAttribute("title", expect.stringContaining("输入框内 Esc"));
act(() => expect(composerStop.current?.()).toBe(true));
await waitFor(() => expect(interruptThread).toHaveBeenCalledWith("thread-a", "execution-a", "v2-thread-stop-execution-a"));
await waitFor(() => expect(screen.getByRole("button", { name: "正在停止" })).toBeDisabled());
act(() => expect(composerStop.current?.()).toBe(false));
threadExecution.mockResolvedValue({ ...execution, state: "stopping" });
await act(async () => pending.resolve({ ...execution, state: "stopping" }));
act(() => expect(composerStop.current?.()).toBe(false));
threadExecution.mockResolvedValue({ ...execution, state: "idle" });
await act(async () => view.queryClient.invalidateQueries({ queryKey: v2QueryKeys.execution("thread-a") }));
await waitFor(() => expect(screen.queryByRole("button", { name: "正在停止" })).not.toBeInTheDocument());
act(() => expect(composerStop.current?.()).toBe(false));
expect(interruptThread).toHaveBeenCalledOnce();
});

it("does not offer a composer stop action when execution permission is missing", async () => {
const interruptThread = vi.fn();
renderConversation(baseClient({ hasRunExecution: false, hasThreadExecutionRead: true, interruptThread,
threadExecution: vi.fn().mockResolvedValue({ version: "thread_execution.v1", thread_id: "thread-a",
execution_id: "execution-a", state: "running", queued_messages: 0, capability_grant: false }) }));
await screen.findByText("正在工作");
act(() => expect(composerStop.current?.()).toBe(false));
expect(interruptThread).not.toHaveBeenCalled();
});

it("keeps the reader's scroll position when new content arrives and restores it on return", async () => {
const view = renderConversation(baseClient());
await screen.findByText("Title thread-a");
Expand Down
Loading
Loading