From ee46029d9831a3767dc66f613f16ad668f5d8a15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:41:48 +0000 Subject: [PATCH 1/4] feat(commands): add Navigate Back and Navigate Forward commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add navigateBack (⌘+[ / ⌘+←) and navigateForward (⌘+] / ⌘+→) commands using the Navigation API (window.navigation.back/forward, canGoBack/canGoForward) - Declare window.navigation in the global Window type - Remove categorize's conflicting ⌘+] alias (keeps primary ⌘+Alt+O) - Reuse ArrowLeft/ArrowRight icons; align their typing with icon convention - Add unit tests for canExecute/exec Co-authored-by: raineorshine <750276+raineorshine@users.noreply.github.com> --- src/@types/index.ts | 15 +++++++ src/commands/__tests__/navigate.ts | 56 +++++++++++++++++++++++++ src/commands/categorize.ts | 5 +-- src/commands/index.ts | 2 + src/commands/navigateBack.ts | 19 +++++++++ src/commands/navigateForward.ts | 19 +++++++++ src/components/icons/ArrowLeftIcon.tsx | 5 +-- src/components/icons/ArrowRightIcon.tsx | 5 +-- 8 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 src/commands/__tests__/navigate.ts create mode 100644 src/commands/navigateBack.ts create mode 100644 src/commands/navigateForward.ts diff --git a/src/@types/index.ts b/src/@types/index.ts index 3db84a82933..3003daffa66 100644 --- a/src/@types/index.ts +++ b/src/@types/index.ts @@ -12,6 +12,21 @@ declare global { debug: (message: string) => void // FIX: Used only in puppeteer test environment. So need way to switch global context based on environment. delay: (ms: number) => Promise + /** + * The Navigation API, used by the Navigate Back/Forward commands. Not yet included in the TypeScript DOM lib, so a minimal subset is declared here. Optional because it is unsupported in some browsers (e.g. older Safari). + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Navigation + */ + navigation?: { + /** True if there is a previous entry in the history that can be navigated to. */ + readonly canGoBack: boolean + /** True if there is a next entry in the history that can be navigated to. */ + readonly canGoForward: boolean + /** Navigates to the previous entry in the history. */ + back: () => void + /** Navigates to the next entry in the history. */ + forward: () => void + } } interface Navigator { diff --git a/src/commands/__tests__/navigate.ts b/src/commands/__tests__/navigate.ts new file mode 100644 index 00000000000..60fd10ed1fc --- /dev/null +++ b/src/commands/__tests__/navigate.ts @@ -0,0 +1,56 @@ +import navigateBack from '../navigateBack' +import navigateForward from '../navigateForward' + +/** Stubs window.navigation with the given capabilities and returns spies for back and forward. */ +const stubNavigation = ({ canGoBack, canGoForward }: { canGoBack: boolean; canGoForward: boolean }) => { + const back = vi.fn() + const forward = vi.fn() + window.navigation = { canGoBack, canGoForward, back, forward } + return { back, forward } +} + +afterEach(() => { + delete window.navigation +}) + +describe('navigateBack', () => { + it('canExecute is false when the Navigation API is unavailable', () => { + delete window.navigation + expect(navigateBack.canExecute!({} as never)).toBe(false) + }) + + it('canExecute mirrors window.navigation.canGoBack', () => { + stubNavigation({ canGoBack: true, canGoForward: false }) + expect(navigateBack.canExecute!({} as never)).toBe(true) + + stubNavigation({ canGoBack: false, canGoForward: true }) + expect(navigateBack.canExecute!({} as never)).toBe(false) + }) + + it('exec navigates back in history', () => { + const { back } = stubNavigation({ canGoBack: true, canGoForward: false }) + navigateBack.exec(vi.fn(), () => ({}) as never, {} as never, { type: 'keyboard' }) + expect(back).toHaveBeenCalledTimes(1) + }) +}) + +describe('navigateForward', () => { + it('canExecute is false when the Navigation API is unavailable', () => { + delete window.navigation + expect(navigateForward.canExecute!({} as never)).toBe(false) + }) + + it('canExecute mirrors window.navigation.canGoForward', () => { + stubNavigation({ canGoBack: false, canGoForward: true }) + expect(navigateForward.canExecute!({} as never)).toBe(true) + + stubNavigation({ canGoBack: true, canGoForward: false }) + expect(navigateForward.canExecute!({} as never)).toBe(false) + }) + + it('exec navigates forward in history', () => { + const { forward } = stubNavigation({ canGoBack: false, canGoForward: true }) + navigateForward.exec(vi.fn(), () => ({}) as never, {} as never, { type: 'keyboard' }) + expect(forward).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/commands/categorize.ts b/src/commands/categorize.ts index 6573cfee1fa..e5414842763 100644 --- a/src/commands/categorize.ts +++ b/src/commands/categorize.ts @@ -9,10 +9,7 @@ const categorizeCommand: Command = { label: 'Categorize', description: 'Move the current thought into a new, empty thought at the same level.', gesture: 'lu', - keyboard: [ - { key: 'o', meta: true, alt: true }, - { key: ']', meta: true }, - ], + keyboard: { key: 'o', meta: true, alt: true }, // Multicursor functionality is handled in the categorize action. // TODO: Implement this with multicursor: true so that we don't need to make a special case of this command in Select All chaining. // See: useFilteredCommands diff --git a/src/commands/index.ts b/src/commands/index.ts index 30962eb88f0..84091103ac5 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -41,6 +41,8 @@ export { default as moveCursorBackward } from './moveCursorBackward' export { default as moveCursorForward } from './moveCursorForward' export { default as moveThoughtDown } from './moveThoughtDown' export { default as moveThoughtUp } from './moveThoughtUp' +export { default as navigateBack } from './navigateBack' +export { default as navigateForward } from './navigateForward' export { default as newGrandChild } from './newGrandChild' export { default as newSubthought } from './newSubthought' export { default as newSubthoughtTop } from './newSubthoughtTop' diff --git a/src/commands/navigateBack.ts b/src/commands/navigateBack.ts new file mode 100644 index 00000000000..3fa0205c56a --- /dev/null +++ b/src/commands/navigateBack.ts @@ -0,0 +1,19 @@ +import { Key } from 'ts-key-enum' +import Command from '../@types/Command' +import ArrowLeftIcon from '../components/icons/ArrowLeftIcon' + +const navigateBackCommand: Command = { + id: 'navigateBack', + label: 'Navigate Back', + description: 'Navigate to the previous page in the browser history.', + keyboard: [ + { key: '[', meta: true }, + { key: Key.ArrowLeft, meta: true }, + ], + multicursor: false, + svg: ArrowLeftIcon, + canExecute: () => !!window.navigation?.canGoBack, + exec: () => window.navigation?.back(), +} + +export default navigateBackCommand diff --git a/src/commands/navigateForward.ts b/src/commands/navigateForward.ts new file mode 100644 index 00000000000..ebaec18b90d --- /dev/null +++ b/src/commands/navigateForward.ts @@ -0,0 +1,19 @@ +import { Key } from 'ts-key-enum' +import Command from '../@types/Command' +import ArrowRightIcon from '../components/icons/ArrowRightIcon' + +const navigateForwardCommand: Command = { + id: 'navigateForward', + label: 'Navigate Forward', + description: 'Navigate to the next page in the browser history.', + keyboard: [ + { key: ']', meta: true }, + { key: Key.ArrowRight, meta: true }, + ], + multicursor: false, + svg: ArrowRightIcon, + canExecute: () => !!window.navigation?.canGoForward, + exec: () => window.navigation?.forward(), +} + +export default navigateForwardCommand diff --git a/src/components/icons/ArrowLeftIcon.tsx b/src/components/icons/ArrowLeftIcon.tsx index 0d52d3f6d8d..a85184334e8 100644 --- a/src/components/icons/ArrowLeftIcon.tsx +++ b/src/components/icons/ArrowLeftIcon.tsx @@ -1,11 +1,10 @@ -import { FC } from 'react' import { css, cx } from '../../../styled-system/css' import { iconRecipe } from '../../../styled-system/recipes' import { token } from '../../../styled-system/tokens' import IconType from '../../@types/IconType' -/** Arrow-left icon used by the Command Universe Back button. */ -const ArrowLeftIcon: FC = ({ size = 24, fill, cssRaw }) => { +/** Arrow-left icon used by the Command Universe Back button and the Navigate Back command. */ +const ArrowLeftIcon = ({ size = 24, fill, cssRaw }: IconType) => { const strokeColor = fill || token('colors.fg') return ( diff --git a/src/components/icons/ArrowRightIcon.tsx b/src/components/icons/ArrowRightIcon.tsx index 15fd9782c25..dbf499b6761 100644 --- a/src/components/icons/ArrowRightIcon.tsx +++ b/src/components/icons/ArrowRightIcon.tsx @@ -1,11 +1,10 @@ -import { FC } from 'react' import { css, cx } from '../../../styled-system/css' import { iconRecipe } from '../../../styled-system/recipes' import { token } from '../../../styled-system/tokens' import IconType from '../../@types/IconType' -/** Arrow-right icon used by the Command Universe Forward button. */ -const ArrowRightIcon: FC = ({ size = 24, fill, cssRaw }) => { +/** Arrow-right icon used by the Command Universe Forward button and the Navigate Forward command. */ +const ArrowRightIcon = ({ size = 24, fill, cssRaw }: IconType) => { const strokeColor = fill || token('colors.fg') return ( From ab215d9d29cb04354706106bf51822cbae06ba4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:52:36 +0000 Subject: [PATCH 2/4] fix(commands): add navigateBack/navigateForward to COMMAND_GROUPS useCommandList requires every command to be in a COMMAND_GROUPS group or have hideFromHelp: true. Add both to the Navigation group. Co-authored-by: raineorshine <750276+raineorshine@users.noreply.github.com> --- src/constants.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/constants.ts b/src/constants.ts index 9f074caeb64..cdcbb637c99 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -527,6 +527,8 @@ export const COMMAND_GROUPS: { 'jumpForward', 'moveCursorBackward', 'moveCursorForward', + 'navigateBack', + 'navigateForward', 'openDesktopCommandUniverse', 'home', 'search', From 7b24cc6df39932b982576c2b3671799ed45ff669 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:13:28 +0000 Subject: [PATCH 3/4] =?UTF-8?q?test(e2e):=20use=20categorize=20primary=20s?= =?UTF-8?q?hortcut=20(=E2=8C=98+Alt+O)=20in=20puppeteer=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The categorize puppeteer test triggered categorize via ⌘+], which is now reassigned to Navigate Forward. Switch it to categorize's retained primary shortcut ⌘+Alt+O. Co-authored-by: raineorshine <750276+raineorshine@users.noreply.github.com> --- src/e2e/puppeteer/__tests__/categorize.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/e2e/puppeteer/__tests__/categorize.ts b/src/e2e/puppeteer/__tests__/categorize.ts index 71d219be953..0006f47b805 100644 --- a/src/e2e/puppeteer/__tests__/categorize.ts +++ b/src/e2e/puppeteer/__tests__/categorize.ts @@ -32,23 +32,23 @@ describe('categorize', () => { await clickThought(topParagraphText) // Perform 1st categorization - await press(']', { meta: true }) + await press('o', { meta: true, alt: true }) await press('1') // Perform 2nd categorization - await press(']', { meta: true }) + await press('o', { meta: true, alt: true }) await press('2') // Perform 3rd categorization - await press(']', { meta: true }) + await press('o', { meta: true, alt: true }) await press('3') // Perform 4th categorization - await press(']', { meta: true }) + await press('o', { meta: true, alt: true }) await press('4') // Perform 5th categorization - await press(']', { meta: true }) + await press('o', { meta: true, alt: true }) await press('5') const imageCategorized = await screenshot() From f345d64f99b1c4658444652abe065a7707abd66c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:23:01 +0000 Subject: [PATCH 4/4] refactor(commands): drop arrow aliases, fall back to window.history for navigate back/forward Co-authored-by: raineorshine <750276+raineorshine@users.noreply.github.com> --- src/commands/__tests__/navigate.ts | 43 ++++++++++++++++++++++++++++-- src/commands/navigateBack.ts | 11 +++----- src/commands/navigateForward.ts | 11 +++----- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/commands/__tests__/navigate.ts b/src/commands/__tests__/navigate.ts index 60fd10ed1fc..94c9a567d49 100644 --- a/src/commands/__tests__/navigate.ts +++ b/src/commands/__tests__/navigate.ts @@ -9,14 +9,30 @@ const stubNavigation = ({ canGoBack, canGoForward }: { canGoBack: boolean; canGo return { back, forward } } +/** Overrides window.history.length for the duration of a test and returns a restore function. */ +const stubHistoryLength = (length: number) => { + const descriptor = Object.getOwnPropertyDescriptor(window.history, 'length') + Object.defineProperty(window.history, 'length', { configurable: true, get: () => length }) + return () => { + if (descriptor) Object.defineProperty(window.history, 'length', descriptor) + } +} + afterEach(() => { delete window.navigation }) describe('navigateBack', () => { - it('canExecute is false when the Navigation API is unavailable', () => { + it('canExecute falls back to window.history.length when the Navigation API is unavailable', () => { delete window.navigation + + const restoreEmpty = stubHistoryLength(1) expect(navigateBack.canExecute!({} as never)).toBe(false) + restoreEmpty() + + const restoreNonEmpty = stubHistoryLength(2) + expect(navigateBack.canExecute!({} as never)).toBe(true) + restoreNonEmpty() }) it('canExecute mirrors window.navigation.canGoBack', () => { @@ -32,12 +48,27 @@ describe('navigateBack', () => { navigateBack.exec(vi.fn(), () => ({}) as never, {} as never, { type: 'keyboard' }) expect(back).toHaveBeenCalledTimes(1) }) + + it('exec falls back to window.history.back when the Navigation API is unavailable', () => { + delete window.navigation + const back = vi.spyOn(window.history, 'back').mockImplementation(() => {}) + navigateBack.exec(vi.fn(), () => ({}) as never, {} as never, { type: 'keyboard' }) + expect(back).toHaveBeenCalledTimes(1) + back.mockRestore() + }) }) describe('navigateForward', () => { - it('canExecute is false when the Navigation API is unavailable', () => { + it('canExecute falls back to window.history.length when the Navigation API is unavailable', () => { delete window.navigation + + const restoreEmpty = stubHistoryLength(1) expect(navigateForward.canExecute!({} as never)).toBe(false) + restoreEmpty() + + const restoreNonEmpty = stubHistoryLength(2) + expect(navigateForward.canExecute!({} as never)).toBe(true) + restoreNonEmpty() }) it('canExecute mirrors window.navigation.canGoForward', () => { @@ -53,4 +84,12 @@ describe('navigateForward', () => { navigateForward.exec(vi.fn(), () => ({}) as never, {} as never, { type: 'keyboard' }) expect(forward).toHaveBeenCalledTimes(1) }) + + it('exec falls back to window.history.forward when the Navigation API is unavailable', () => { + delete window.navigation + const forward = vi.spyOn(window.history, 'forward').mockImplementation(() => {}) + navigateForward.exec(vi.fn(), () => ({}) as never, {} as never, { type: 'keyboard' }) + expect(forward).toHaveBeenCalledTimes(1) + forward.mockRestore() + }) }) diff --git a/src/commands/navigateBack.ts b/src/commands/navigateBack.ts index 3fa0205c56a..c0f1f4273c8 100644 --- a/src/commands/navigateBack.ts +++ b/src/commands/navigateBack.ts @@ -1,4 +1,3 @@ -import { Key } from 'ts-key-enum' import Command from '../@types/Command' import ArrowLeftIcon from '../components/icons/ArrowLeftIcon' @@ -6,14 +5,12 @@ const navigateBackCommand: Command = { id: 'navigateBack', label: 'Navigate Back', description: 'Navigate to the previous page in the browser history.', - keyboard: [ - { key: '[', meta: true }, - { key: Key.ArrowLeft, meta: true }, - ], + keyboard: { key: '[', meta: true }, multicursor: false, svg: ArrowLeftIcon, - canExecute: () => !!window.navigation?.canGoBack, - exec: () => window.navigation?.back(), + // Prefer the Navigation API's canGoBack when available; otherwise fall back to the history length as a rough approximation of whether there is anywhere to go back to. + canExecute: () => (window.navigation ? window.navigation.canGoBack : window.history.length > 1), + exec: () => (window.navigation ? window.navigation.back() : window.history.back()), } export default navigateBackCommand diff --git a/src/commands/navigateForward.ts b/src/commands/navigateForward.ts index ebaec18b90d..03fa2300034 100644 --- a/src/commands/navigateForward.ts +++ b/src/commands/navigateForward.ts @@ -1,4 +1,3 @@ -import { Key } from 'ts-key-enum' import Command from '../@types/Command' import ArrowRightIcon from '../components/icons/ArrowRightIcon' @@ -6,14 +5,12 @@ const navigateForwardCommand: Command = { id: 'navigateForward', label: 'Navigate Forward', description: 'Navigate to the next page in the browser history.', - keyboard: [ - { key: ']', meta: true }, - { key: Key.ArrowRight, meta: true }, - ], + keyboard: { key: ']', meta: true }, multicursor: false, svg: ArrowRightIcon, - canExecute: () => !!window.navigation?.canGoForward, - exec: () => window.navigation?.forward(), + // Prefer the Navigation API's canGoForward when available; otherwise fall back to the history length as a rough approximation of whether there is anywhere to go forward to. + canExecute: () => (window.navigation ? window.navigation.canGoForward : window.history.length > 1), + exec: () => (window.navigation ? window.navigation.forward() : window.history.forward()), } export default navigateForwardCommand