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
39 changes: 39 additions & 0 deletions docs/evidence/chat-navigation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Previous and next chat shortcuts

The default shortcuts are `Ctrl/⌘+Shift+[` and `Ctrl/⌘+Shift+]`. Both are listed in Settings → Shortcuts and can be changed or cleared. An existing custom binding takes priority over a newly introduced default.

Navigation follows the loaded chat rows rendered by the sidebar: pinned chats, expanded project groups, and local chats, with the current execution filter applied. It stops at either edge or when the selected chat is outside that list. The sidebar reports its rendered order directly; navigation does not repeat its grouping or sorting logic.

Explicit keyboard navigation uses the same accepted-navigation callback as a sidebar click. Cancelling the unsubmitted-file-edits dialog preserves the destination chat's unread marker. Drafts continue through the existing per-chat storage.

## Captures

All images are real Windows Chromium captures at 1280 × 900. The production renderer was served by `cmd/workbenchjourney`, using the production API with its offline provider and disposable projects. The replies shown came from actual `workspace_read` calls and persisted assistant messages. No API success responses or page content were substituted.

- Original baseline: `52c7da77fd83251b61d1696735a3d98f521ce37f`, built before editing. Bundle digest: `78d6b88f6c424bb8718cf4779e7a4fc6f3cc24cd7c861580b0471563a14ddea6`.
- Final captures: this change on `e790ec4b7856a5eafea4d2ea7d441f05ec677963`, including the unread navigation-acceptance fix and sidebar resizing. Bundle digest: `2d86a8dfd8717c392176ac2b92e7b39482f26816da2d69647f3e7c47572c94e4`.

| Image | Observed result |
| --- | --- |
| [Before, Chinese](before-shortcuts-zh.png) / [English](before-shortcuts-en.png) | Original six shortcuts; adjacent-chat actions are absent. |
| [After, Chinese](after-shortcuts-zh.png) / [English](after-shortcuts-en.png) | Previous/next chat, default keys and sidebar-order explanation are visible. |
| [Before navigation](before-navigation.png) | From B, `Ctrl+Shift+]` leaves B selected despite A being the next visible chat. The unsent draft remains. |
| [Next chat](after-next-a.png) | The same physical key opens A; A has its own rich-text draft. |
| [Previous chat](after-previous-b.png) | `Ctrl+Shift+[` returns to B and restores its unsent plain-text draft. |

## Verification

```text
npm test -- src/v2/app.test.tsx src/v2/components/sidebar.test.tsx src/v2/components/keyboard-settings.test.tsx src/v2/navigation-acceptance.test.ts
4 files, 85 tests passed

npm run build
Passed (existing large-chunk warning)

git diff --check
Passed
```

The browser journey verified both edges without wrapping, A/B round trips, plain/rich composer draft restoration, settings/search/menu protection, collapsed projects, execution filters, and navigation after expanding tool details. Pinning A changed the actual sidebar order to A → B; the shortcuts followed that order. A manually unread chat became read after an accepted keyboard switch.

Component tests additionally cover Ctrl/Meta physical `BracketLeft`/`BracketRight` events, remapping persistence, pre-existing custom bindings, IME/repeated keys, editors/terminals, popovers, new-chat/inspector contexts, and cancelling/accepting the actual file-leave confirmation provider. macOS was not tested on a native device; Meta bindings were covered in component tests.
Binary file added docs/evidence/chat-navigation/after-next-a.png
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.
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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
95 changes: 94 additions & 1 deletion web/src/v2/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { LocaleProvider, useLocale } from "../lib/locale";
import { V2Workbench } from "./app";
import * as desktopBridge from "../lib/desktop-bridge";
import { v2FileReferenceKey } from "./components/file-context";
import { useFileEditLeaveEntry } from "../components/file-edit-drafts";

function render(ui: ReactElement) {
return renderComponent(<LocaleProvider>{ui}</LocaleProvider>);
Expand All @@ -22,7 +23,7 @@ function LocaleSwitch() {
return <button onClick={() => setLocale(locale === "zh-CN" ? "en-US" : "zh-CN")} type="button">Switch test language</button>;
}

const branchNavigation = vi.hoisted(() => ({ complete: undefined as ((thread: ThreadView) => void) | undefined }));
const branchNavigation = vi.hoisted(() => ({ complete: undefined as ((thread: ThreadView) => void) | undefined, dirtyFile: false }));

vi.mock("./components/conversation", () => ({
V2Conversation: ({ threadID, view = "conversation", draft, onDraftChange, reviewEntry, onBranchCreated }: {
Expand All @@ -34,6 +35,8 @@ vi.mock("./components/conversation", () => ({
onBranchCreated?: (thread: ThreadView) => void;
}) => {
branchNavigation.complete = onBranchCreated;
useFileEditLeaveEntry({ path: "README.md", dirty: branchNavigation.dirtyFile, busy: false, canSave: false,
save: async () => undefined, keep: () => { branchNavigation.dirtyFile = false; }, discard: vi.fn() });
return (
<div data-testid="v2-conversation" data-view={view}>{threadID}
<textarea aria-label="任务草稿 fixture" value={draft} onChange={(event) => onDraftChange(event.target.value)} />
Expand Down Expand Up @@ -88,6 +91,7 @@ const readyModelCatalog = () => vi.fn().mockResolvedValue(selectableModelCatalog

afterEach(() => {
branchNavigation.complete = undefined;
branchNavigation.dirtyFile = false;
cleanup();
useConnectionStore.getState().disconnect();
vi.restoreAllMocks();
Expand All @@ -111,6 +115,95 @@ function expectNoNavigationWrites(client: APIClient) {
}
}

describe("V2Workbench adjacent chat navigation", () => {
it.each([false, true])("preserves drafts, clears unread only after an accepted switch and stops at edges (unsaved file: %s)", async (dirtyFile) => {
branchNavigation.dirtyFile = dirtyFile;
const other = { ...createdThread, id: "thread-next", title: "Next conversation" };
const scope = `ds1_${"7".repeat(64)}`;
useConnectionStore.getState().setHealth({ status: "ok", api_version: "api.v1", app_version: "fixture", schema_version: 216, data_store_id: scope });
const storageKey = `v2_recovery.v1:${encodeURIComponent(window.location.origin)}:${encodeURIComponent("/api/v1")}:${scope}:reply-read-state`;
localStorage.setItem(storageKey, JSON.stringify({ version: "v2_recovery.v1", value: { cursor: "known-feed", threads: {
[other.id]: { pending: [], seen: [], manualUnread: true },
} } }));
window.history.replaceState({}, "", `#/threads/${createdThread.id}`);
const client = Object.assign(navigationClient(), { baseURL: "/api/v1" });
vi.mocked(client.getPage).mockImplementation(async (path: string) => ({
items: path === "/workspaces" ? [workspace] : [createdThread, other], page: { limit: 100 }, requestID: path,
}) as never);
vi.mocked(client.get).mockResolvedValue({ version: "thread_reply_updates.v1", data_store_id: scope, cursor: "known-feed", has_more: false, items: [] });
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<V2Workbench client={client} /></QueryClientProvider>);
const nextRow = await screen.findByRole("button", { name: other.title });
expect(nextRow).toHaveAccessibleDescription(expect.stringContaining("未读"));
const press = (direction: "Left" | "Right") => {
const event = new KeyboardEvent("keydown", { key: direction === "Left" ? "{" : "}", code: `Bracket${direction}`, ctrlKey: true, shiftKey: true, cancelable: true });
fireEvent(window, event);
expect(event.defaultPrevented).toBe(true);
};
const draft = () => screen.getByRole("textbox", { name: "任务草稿 fixture" });
fireEvent.change(draft(), { target: { value: "Draft for the first chat" } });
press("Left");
expect(window.location.hash).toBe(`#/threads/${createdThread.id}`);
press("Right");
if (dirtyFile) {
expect(window.location.hash).toBe(`#/threads/${createdThread.id}`);
expect(nextRow).toHaveAccessibleDescription(expect.stringContaining("未读"));
fireEvent.click(screen.getByRole("button", { name: "继续编辑" }));
expect(window.location.hash).toBe(`#/threads/${createdThread.id}`);
expect(nextRow).toHaveAccessibleDescription(expect.stringContaining("未读"));
press("Right");
await userEvent.click(screen.getByRole("button", { name: "保留草稿" }));
await waitFor(() => expect(window.location.hash).toBe(`#/threads/${other.id}`));
}
expect(window.location.hash).toBe(`#/threads/${other.id}`);
expect(nextRow).not.toHaveAccessibleDescription(expect.stringContaining("未读"));
expect(draft()).toHaveValue("");
fireEvent.change(draft(), { target: { value: "Draft for the next chat" } });
press("Right");
expect(window.location.hash).toBe(`#/threads/${other.id}`);
press("Left");
expect(draft()).toHaveValue("Draft for the first chat");
press("Right");
expect(draft()).toHaveValue("Draft for the next chat");
expectNoNavigationWrites(client);
});

it("keeps search, settings, inspector and a new chat in place", async () => {
await import("./components/settings");
window.history.replaceState({}, "", `#/threads/${createdThread.id}`);
const client = navigationClient();
vi.mocked(client.getPage).mockImplementation(async (path: string) => ({
items: path === "/workspaces" ? [workspace] : [createdThread, { ...createdThread, id: "thread-next", title: "Next conversation" }],
page: { limit: 100 }, requestID: path,
}) as never);
render(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<V2Workbench client={client} /></QueryClientProvider>);
await screen.findByRole("button", { name: "Next conversation" });
const stays = () => {
const before = window.location.hash;
const event = new KeyboardEvent("keydown", { key: "}", code: "BracketRight", ctrlKey: true, shiftKey: true, cancelable: true });
fireEvent(window, event);
expect(window.location.hash).toBe(before);
expect(event.defaultPrevented).toBe(false);
};
fireEvent.keyDown(window, { key: "k", code: "KeyK", ctrlKey: true });
expect(screen.getByRole("searchbox", { name: "搜索对话" })).toBeInTheDocument();
stays();
fireEvent.click(screen.getByRole("button", { name: "搜索" }));
fireEvent.keyDown(window, { key: ",", code: "Comma", ctrlKey: true });
await screen.findByRole("heading", { name: "常规", level: 1 });
stays();
fireEvent.click(screen.getByRole("button", { name: "返回应用" }));
fireEvent.click(screen.getByRole("button", { name: "观察与记录" }));
expect(window.location.hash).toContain("/inspector");
stays();
fireEvent.keyDown(window, { key: "o", code: "KeyO", ctrlKey: true, shiftKey: true });
expect(window.location.hash).toBe("#/new");
stays();
expectNoNavigationWrites(client);
});
});

it.each([false, true])("moves the text draft while retaining a late file reference: %s", async (lateUpload) => {
const storeID = `ds1_${"d".repeat(64)}`;
const prefix = `v2_recovery.v1:${encodeURIComponent(window.location.origin)}:${encodeURIComponent("/api/v1")}:${storeID}:`;
Expand Down
20 changes: 16 additions & 4 deletions web/src/v2/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
return () => media.removeListener?.(onChange);
}, []);
const [searchOpen, setSearchOpen] = useState(false);
const visibleChatOrder = useRef<readonly string[]>([]);
const [searchFocusRequest, setSearchFocusRequest] = useState(0);
useEffect(() => {
if (!searchFocusRequest || !searchOpen || !sidebarVisible) return;
Expand Down Expand Up @@ -547,6 +548,17 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
navigate({ kind: "thread", threadID, ...(route.view ? { view: route.view } : {}) }, false, onAccepted);
closeNavigationSidebar();
};
const openSidebarConversation = (threadID: string) => openConversation(threadID, () => {
unread.open(threadID);
setReadRequest((current) => ({ threadID, revision: current.revision + 1 }));
});
const openAdjacentChat = (direction: -1 | 1) => {
if (!selectedThreadID || surface !== "conversation" || view !== "conversation" || route.tool || searchOpen) return false;
const index = visibleChatOrder.current.indexOf(selectedThreadID);
const next = index < 0 ? undefined : visibleChatOrder.current[index + direction];
if (next) openSidebarConversation(next);
// At an edge, consume the shortcut without wrapping or leaving this chat.
};
const startNew = (destination?: string) => {
updateModelSetupToken("");
const currentThread = queryClient.getQueryData<ThreadDetailView>(
Expand Down Expand Up @@ -626,6 +638,8 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
newChat: () => startNew(),
search: () => openSearch(false),
findInChat: () => openSearch(true),
previousChat: () => openAdjacentChat(-1),
nextChat: () => openAdjacentChat(1),
settings: openSettings,
sidebar: () => setSidebarVisible(!sidebarVisible),
composer: () => {
Expand Down Expand Up @@ -661,10 +675,8 @@ function V2WorkbenchContent({ client }: { client: APIClient }) {
setSearchOpen(open);
setSearchCurrentThread(false);
if (!open) { setThreadSearch(""); setSearchComposing(false); }
}} onSelectThread={(id) => openConversation(id, () => {
unread.open(id);
setReadRequest((current) => ({ threadID: id, revision: current.revision + 1 }));
})} searchOpen={searchOpen}
}} onSelectThread={openSidebarConversation} searchOpen={searchOpen}
onVisibleChatOrder={(ids) => { visibleChatOrder.current = ids; }}
unreadThreadIDs={unread.unreadThreadIDs} onReadStateChange={unread.setRead}
search={threadSearch} appliedSearch={appliedThreadSearch} searchPending={searchPending}
searchTooLong={searchTooLong}
Expand Down
15 changes: 12 additions & 3 deletions web/src/v2/chat-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const shortcutCommands = [
{ id: "newChat", zh: "新对话", en: "New chat", binding: "Mod+Shift+O" },
{ id: "search", zh: "搜索聊天", en: "Search chats", binding: "Mod+K" },
{ id: "findInChat", zh: "在当前聊天中查找", en: "Find in current chat", binding: "Mod+F" },
{ id: "previousChat", zh: "上一聊天", en: "Previous chat", binding: "Mod+Shift+[" },
{ id: "nextChat", zh: "下一聊天", en: "Next chat", binding: "Mod+Shift+]" },
{ id: "settings", zh: "打开设置", en: "Open settings", binding: "Mod+," },
{ id: "sidebar", zh: "展开/收起侧栏", en: "Toggle sidebar", binding: "Mod+B" },
{ id: "composer", zh: "聚焦输入框", en: "Focus composer", binding: "Mod+Shift+I" },
Expand Down Expand Up @@ -46,15 +48,19 @@ export function sendsOnEnter(mode: SendPreference, text: string, modified: boole
return modified || (!structured && mode !== "modifier" && (mode !== "multiline" || !text.includes("\n")));
}
export function shortcutBinding(preferences: ChatPreferences, id: ShortcutCommand) {
return preferences.shortcuts[id] ?? shortcutCommands.find((command) => command.id === id)!.binding;
if (preferences.shortcuts[id] !== undefined) return preferences.shortcuts[id];
const binding = shortcutCommands.find((command) => command.id === id)!.binding;
// A newly introduced default must not take over an existing custom binding.
return Object.values(preferences.shortcuts).includes(binding) ? "" : binding;
}
export function shortcutLabel(binding: string) {
return binding.replace("Mod", /Mac|iPhone|iPad/u.test(navigator.platform) ? "⌘" : "Ctrl");
}
export function eventShortcut(event: Pick<KeyboardEvent, "key" | "code" | "ctrlKey" | "metaKey" | "altKey" | "shiftKey">): string {
if ((!event.ctrlKey && !event.metaKey) || (event.ctrlKey && event.metaKey)) return "";
const key = /^Key[A-Z]$/u.test(event.code) ? event.code.slice(3)
: /^Digit\d$/u.test(event.code) ? event.code.slice(5) : event.key.toUpperCase();
: /^Digit\d$/u.test(event.code) ? event.code.slice(5)
: event.code === "BracketLeft" ? "[" : event.code === "BracketRight" ? "]" : event.key.toUpperCase();
if (!/^[A-Z0-9,./;[\]\\'=-]$/u.test(key)) return "";
return ["Mod", ...(event.altKey ? ["Alt"] : []), ...(event.shiftKey ? ["Shift"] : []), key].join("+");
}
Expand All @@ -80,7 +86,10 @@ export function useChatShortcuts(actions: Record<ShortcutCommand, () => void | b
if (!binding) return;
const command = shortcutCommands.find(({ id }) => shortcutBinding(latest.current.preferences, id) === binding);
if (!command) return;
if (command.id === "findInChat" && event.target instanceof Element && event.target.closest(".monaco-editor, .xterm")) return;
const chatNavigation = command.id === "previousChat" || command.id === "nextChat";
if ((command.id === "findInChat" || chatNavigation) && event.target instanceof Element &&
event.target.closest(".monaco-editor, .xterm")) return;
if (chatNavigation && document.querySelector('[role="dialog"], [role="menu"], [role="listbox"], .v2-project-section-menu[open]')) return;
// A contextual action may leave the native shortcut available outside its surface.
if (latest.current.actions[command.id]() !== false) event.preventDefault();
};
Expand Down
Loading
Loading