Skip to content

Commit b980e44

Browse files
committed
Surface recently-used commands in the command palette
The command palette (⌘K) listed every action grouped by category but kept no memory of what you run — you re-search even for the command you ran seconds ago. Add a "Recently used" group pinned to the top, the standard command-palette convention (VS Code, Raycast, Linear). - Persist the most-recently-run action IDs in localStorage (newest first, de-duplicated, capped at 6) via the shared local-storage helper. - Record each action run from the palette; show the recents in a dedicated "Recently used" group above the categories when the query is empty, and hide it while searching so relevance ranking takes over. - Filter recents to actions that still exist, aren't excluded, and can run right now, so the group never surfaces a stale or dead command. - Extract the ordering into a pure `pushRecent` module with unit tests. - Add the `commands.recent` heading key across all 7 locales. - Add a CDP assertion driving the full lifecycle (seed → render → hide on search → record on run → newest-first on reopen).
1 parent 26f609c commit b980e44

13 files changed

Lines changed: 314 additions & 11 deletions

File tree

apps/electron/src/renderer/components/CommandPalette.tsx

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ import { useRegisterModal } from '@/context/ModalContext'
3434
import {
3535
useAction,
3636
useActionRegistry,
37+
actions as actionDefinitions,
3738
actionsByCategory,
3839
ACTION_LABEL_KEYS,
3940
categoryLabelKey,
4041
type ActionId,
4142
} from '@/actions'
43+
import { readRecents, recordRecent } from './command-palette-recents'
4244

4345
// Actions that should not appear as palette entries:
4446
// - the palette's own open action (running it from inside the palette is a no-op)
@@ -54,14 +56,35 @@ export function CommandPalette() {
5456
const { t } = useTranslation()
5557
const { execute, canExecute, getHotkeyDisplay } = useActionRegistry()
5658
const [open, setOpen] = useState(false)
59+
const [search, setSearch] = useState('')
5760

5861
// ⌘K / Ctrl+K toggles the palette.
5962
useAction('app.commandPalette', () => setOpen(prev => !prev))
6063

64+
// Reset the query each time the palette opens so it always starts fresh
65+
// (and the "Recently used" group — shown only for an empty query — is visible).
66+
const handleOpenChange = useCallback((next: boolean) => {
67+
if (next) setSearch('')
68+
setOpen(next)
69+
}, [])
70+
6171
// Participate in the layered modal stack (Cmd+W / X close the topmost modal).
6272
const handleClose = useCallback(() => setOpen(false), [])
6373
useRegisterModal(open, handleClose)
6474

75+
// Turn a runnable action id into a display-ready palette entry.
76+
const toItem = useCallback(
77+
(id: ActionId) => {
78+
const labelKey = ACTION_LABEL_KEYS[id]
79+
return {
80+
id,
81+
label: labelKey ? t(labelKey) : actionDefinitions[id].label,
82+
hotkey: getHotkeyDisplay(id),
83+
}
84+
},
85+
[t, getHotkeyDisplay],
86+
)
87+
6588
// Build the grouped, display-ready action list once.
6689
const groups = useMemo(() => {
6790
return Object.entries(actionsByCategory)
@@ -75,23 +98,31 @@ export function CommandPalette() {
7598
// is disabled, so the palette never shows a dead entry. Evaluated as
7699
// the palette opens, i.e. against the focus you're returning to.
77100
.filter(action => canExecute(action.id as ActionId))
78-
.map(action => {
79-
const id = action.id as ActionId
80-
const labelKey = ACTION_LABEL_KEYS[id]
81-
return {
82-
id,
83-
label: labelKey ? t(labelKey) : action.label,
84-
hotkey: getHotkeyDisplay(id),
85-
}
86-
}),
101+
.map(action => toItem(action.id as ActionId)),
87102
}))
88103
.filter(group => group.items.length > 0)
89104
// getHotkeyDisplay / t are stable enough for a menu; recompute on open.
90105
// eslint-disable-next-line react-hooks/exhaustive-deps
91106
}, [open])
92107

108+
// "Recently used": the most-recently-run actions, newest first. Kept to
109+
// entries that still exist, aren't excluded from the palette, and can run
110+
// right now — so the group never surfaces a stale or dead command. Only
111+
// shown for an empty query (a search should rank by relevance, not recency).
112+
const recentItems = useMemo(() => {
113+
return readRecents()
114+
.filter((id): id is ActionId => id in actionDefinitions)
115+
.filter(id => !EXCLUDED_ACTIONS.has(id))
116+
.filter(id => canExecute(id))
117+
.map(id => toItem(id))
118+
// eslint-disable-next-line react-hooks/exhaustive-deps
119+
}, [open])
120+
const showRecent = search.trim() === '' && recentItems.length > 0
121+
93122
const runAction = useCallback(
94123
(id: ActionId) => {
124+
// Remember this action so it surfaces in "Recently used" next time.
125+
recordRecent(id)
95126
// Close first, then run on the next tick. Closing the dialog restores
96127
// focus to the element that was active before the palette opened, so the
97128
// action runs in the app's real focus context — actions that open a panel
@@ -104,7 +135,7 @@ export function CommandPalette() {
104135
)
105136

106137
return (
107-
<Dialog open={open} onOpenChange={setOpen}>
138+
<Dialog open={open} onOpenChange={handleOpenChange}>
108139
<DialogContent
109140
showCloseButton={false}
110141
className="overflow-hidden p-0"
@@ -121,11 +152,30 @@ export function CommandPalette() {
121152
<CommandInput
122153
data-testid="command-palette-input"
123154
placeholder={t('commands.searchCommands')}
155+
value={search}
156+
onValueChange={setSearch}
124157
/>
125158
<CommandList>
126159
<CommandEmpty data-testid="command-palette-empty">
127160
{t('common.noResultsFound')}
128161
</CommandEmpty>
162+
{showRecent && (
163+
<CommandGroup heading={t('commands.recent')}>
164+
{recentItems.map(item => (
165+
<CommandItem
166+
key={`recent:${item.id}`}
167+
value={`recent ${item.label} ${item.id}`}
168+
data-testid="command-palette-recent-item"
169+
onSelect={() => runAction(item.id)}
170+
>
171+
<span>{item.label}</span>
172+
{item.hotkey && (
173+
<CommandShortcut>{item.hotkey}</CommandShortcut>
174+
)}
175+
</CommandItem>
176+
))}
177+
</CommandGroup>
178+
)}
129179
{groups.map(group => (
130180
<CommandGroup key={group.category} heading={group.heading}>
131181
{group.items.map(item => (
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { pushRecent, MAX_RECENTS } from '../command-palette-recents'
3+
4+
describe('pushRecent', () => {
5+
it('prepends a new id as most recent', () => {
6+
expect(pushRecent(['a', 'b'], 'c')).toEqual(['c', 'a', 'b'])
7+
})
8+
9+
it('moves an existing id to the front without duplicating it', () => {
10+
expect(pushRecent(['a', 'b', 'c'], 'c')).toEqual(['c', 'a', 'b'])
11+
expect(pushRecent(['a', 'b', 'c'], 'b')).toEqual(['b', 'a', 'c'])
12+
})
13+
14+
it('is idempotent when re-running the current top action', () => {
15+
expect(pushRecent(['a', 'b'], 'a')).toEqual(['a', 'b'])
16+
})
17+
18+
it('caps the list at the requested size, dropping the oldest', () => {
19+
expect(pushRecent(['a', 'b', 'c'], 'd', 3)).toEqual(['d', 'a', 'b'])
20+
})
21+
22+
it('defaults the cap to MAX_RECENTS', () => {
23+
const many = Array.from({ length: MAX_RECENTS + 3 }, (_, i) => `id-${i}`)
24+
const result = pushRecent(many, 'fresh')
25+
expect(result).toHaveLength(MAX_RECENTS)
26+
expect(result[0]).toBe('fresh')
27+
})
28+
29+
it('does not mutate the input list', () => {
30+
const input = ['a', 'b']
31+
const copy = [...input]
32+
pushRecent(input, 'c')
33+
expect(input).toEqual(copy)
34+
})
35+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Command-palette "recently used" history.
3+
*
4+
* A small persisted list of the action IDs most recently run from the command
5+
* palette, newest first. The palette surfaces these in a dedicated "Recently
6+
* used" group at the top so the commands you actually use are one keystroke
7+
* away — the standard command-palette convention (VS Code, Raycast, Linear).
8+
*
9+
* The ordering logic (`pushRecent`) is a pure function so it can be unit-tested
10+
* without a DOM; the read/record wrappers persist through the shared
11+
* localStorage helper.
12+
*/
13+
14+
import { get, set, KEYS } from '@/lib/local-storage'
15+
16+
/** How many recent actions to remember. */
17+
export const MAX_RECENTS = 6
18+
19+
/**
20+
* Return a new recents list with `id` moved to the front (most recent).
21+
*
22+
* Pure: no I/O. Removes any earlier occurrence of `id` (so an action never
23+
* appears twice), prepends it, and caps the list at `cap` entries.
24+
*/
25+
export function pushRecent(list: readonly string[], id: string, cap = MAX_RECENTS): string[] {
26+
const withoutId = list.filter((entry) => entry !== id)
27+
return [id, ...withoutId].slice(0, Math.max(0, cap))
28+
}
29+
30+
/** Read the persisted recent action IDs (newest first). Never throws. */
31+
export function readRecents(): string[] {
32+
const value = get<unknown>(KEYS.commandPaletteRecents, [])
33+
if (!Array.isArray(value)) return []
34+
return value.filter((entry): entry is string => typeof entry === 'string')
35+
}
36+
37+
/** Record `id` as the most recently run action and persist the trimmed list. */
38+
export function recordRecent(id: string): void {
39+
set(KEYS.commandPaletteRecents, pushRecent(readRecents(), id))
40+
}

apps/electron/src/renderer/lib/local-storage.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const KEYS = {
5050
// Settings navigation
5151
lastSettingsSubpage: 'last-settings-subpage',
5252

53+
// Command palette (most-recently-run action IDs, newest first)
54+
commandPaletteRecents: 'command-palette-recents',
55+
5356
// Appearance
5457
showConnectionIcons: 'show-connection-icons',
5558

docs/loop/feature-ledger.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ log, not the system of record.
3232

3333
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3434
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
35-
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | pr-open | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-02 | `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). typecheck/`bun test` zero-delta vs main (3578 pass/56 fail on both); renderer build ✅. **CDP could not run locally**: this sandbox's egress policy 403s the Electron binary download and the `libsignal` GitHub dep (WhatsApp worker), so the app can't be built/launched here — assertion included for CI/reviewer. |
35+
| command-palette-recents | "Recently used" section in the Command Palette (⌘K) | VS Code ⌘⇧P / Raycast / Linear ⌘K / Claude Code Desktop command menu | frontend-only | in-progress | [#56](https://github.com/modelstudioai/openwork/issues/56) || loop/command-palette-recents | 2026-07-05 | Palette (#42) had no memory of what you run. Adds a persisted recents list (`localStorage` `command-palette-recents`, dedupe/cap-6, newest-first) surfaced as a top "Recently used" group, hidden while searching. Pure `pushRecent` module + 6 unit tests (pass). One new i18n key `commands.recent` × 7 locales. typecheck/`bun test` zero-delta vs main (56 baseline fails on both, 0 new); renderer build ✅. **CDP could not run locally** (same env block as prior rounds: egress 403s Electron binary + `libsignal`/WhatsApp-worker dep) — assertion included for CI/reviewer. |
36+
| thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking menu | Claude Code Desktop effort-menu shortcut (⌘⇧E) | frontend-only | pr-open | [#54](https://github.com/modelstudioai/openwork/issues/54) | [#55](https://github.com/modelstudioai/openwork/pull/55) | loop/thinking-menu-shortcut | 2026-07-05 | Reconciled from GitHub. Awaiting review. |
37+
| composer-prompt-history | Recall previously-sent prompts with ↑/↓ in the composer | Codex desktop up-arrow recall / Claude CLI / shell history | frontend-only | pr-open | [#52](https://github.com/modelstudioai/openwork/issues/52) | [#53](https://github.com/modelstudioai/openwork/pull/53) | loop/composer-prompt-history | 2026-07-05 | Reconciled from GitHub. Awaiting review. |
38+
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / OS `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | loop/reduce-motion | 2026-07-05 | Reconciled from GitHub. Awaiting review. Touches Appearance → Interface + renderer root `MotionConfig`. |
39+
| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude / ChatGPT / Codex desktop composer maximize | frontend-only | pr-open | [#48](https://github.com/modelstudioai/openwork/issues/48) | [#49](https://github.com/modelstudioai/openwork/pull/49) | loop/composer-expand | 2026-07-05 | Reconciled from GitHub. Awaiting review. |
40+
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button for the transcript | Claude Code Desktop / ChatGPT / Codex desktop | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | loop/scroll-to-bottom | 2026-07-05 | Reconciled from GitHub. Awaiting review. Adds an `e2e/app.ts` `seed(profileDirs)` hook (on-disk session seeding) — not yet on `main`, so transcript-dependent assertions must wait for this to land. |
41+
| app-search-cmdf-bug | `Cmd+F` / `app.search` shortcut doesn't activate session search | Pre-existing OpenWork bug (not a desktop-feature gap) | frontend-only | proposed | [#43](https://github.com/modelstudioai/openwork/issues/43) ||| 2026-07-05 | Open bug issue, no PR/branch yet. Recorded for tracking; not a Claude/Codex feature gap. |
42+
| thinking-level-picker | Thinking-level (reasoning effort) picker in the chat composer | Claude Code Desktop effort menu (⌘⇧E) + OpenWork's own model picker | frontend-only | merged | [#44](https://github.com/modelstudioai/openwork/issues/44) | [#45](https://github.com/modelstudioai/openwork/pull/45) | loop/thinking-level-picker | 2026-07-05 | **Merged into `main`.** `thinkingLevel`/`onThinkingLevelChange` already plumbed to `FreeFormInput`; only the UI trigger was missing. Reuses `thinking.*` + `settings.ai.thinking` i18n keys (zero new keys). |
3643
| command-palette | Global command palette (⌘K/Ctrl+K) to search & run any action | Claude Code Desktop ⌘K / VS Code & Codex ⌘⇧P / Linear ⌘K | frontend-only | merged | [#41](https://github.com/modelstudioai/openwork/issues/41) | [#42](https://github.com/modelstudioai/openwork/pull/42) | loop/command-palette | 2026-07-02 | Merged into `main`. Reuses action registry `execute()` + cmdk primitives; zero new i18n keys. CDP e2e 2/2 pass. typecheck/test +0 vs main. |
3744
| settings-search | Searchable/filterable settings navigation | Claude Code Desktop / VS Code / Codex desktop settings search | frontend-only | merged | [#39](https://github.com/modelstudioai/openwork/issues/39) | [#40](https://github.com/modelstudioai/openwork/pull/40) | loop/settings-search | 2026-07-01 | Merged into `main`. Filters `SettingsNavigator` by title+description; reuses `common.search`/`common.noResultsFound` (no new locale keys). Also hardened `e2e/app.ts` teardown (per-launch profile dir + setsid process-group kill) so multiple CDP assertions run under headless xvfb. |

0 commit comments

Comments
 (0)