Skip to content

Commit 4393be0

Browse files
DragonnZhangclaude
andauthored
Surface recently-used commands in the Command Palette (⌘K) (#57)
* 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). * docs(loop): mark command-palette-recents pr-open (#57) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0909b26 commit 4393be0

13 files changed

Lines changed: 307 additions & 10 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
@@ -56,6 +56,9 @@ export const KEYS = {
5656
// Settings navigation
5757
lastSettingsSubpage: 'last-settings-subpage',
5858

59+
// Command palette (most-recently-run action IDs, newest first)
60+
commandPaletteRecents: 'command-palette-recents',
61+
5962
// Appearance
6063
showConnectionIcons: 'show-connection-icons',
6164
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide

docs/loop/feature-ledger.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ log, not the system of record.
3333

3434
| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
3535
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
36+
| command-palette-recents | "Recently used" section in the Command Palette (⌘K) | VS Code ⌘⇧P / Raycast / Linear ⌘K / Claude Code Desktop command menu | frontend-only | pr-open | [#56](https://github.com/modelstudioai/openwork/issues/56) | [#57](https://github.com/modelstudioai/openwork/pull/57) | 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. |
3637
| thinking-menu-shortcut | Keyboard shortcut (⌘⇧E) to open the composer's thinking (effort) menu | Claude Code Desktop effort-menu shortcut (⌘⇧E); explicit follow-up flagged in #44 | 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-04 | New `chat.openThinkingMenu` action (`mod+shift+e`) → scoped `craft:open-composer-menu` event → `FreeFormInput` opens its already-controlled `thinkingDropdownOpen`. Mirrors `craft:focus-input`/`shouldHandleScopedInputEvent` for multi-panel safety. Auto-appears in Command Palette + Shortcuts reference. 1 new i18n key across 7 locales. Avoids ⌘⇧I (reserved for DevTools) / ⌘⇧M (permission mode already has Shift+Tab). typecheck zero-delta (11 pre-existing); `bun test` zero-delta (56 identical failures, diffed vs main); i18n parity OK; renderer build ✅; assertion transpiles ✅. **CDP blocked locally** (same egress limit: `libsignal` GitHub dep + Electron binary 403). |
3738
| prompt-history-recall | Recall previously-sent prompts in the composer with Up/Down arrows | Claude Code CLI `↑` history + Codex desktop "recover your previous prompt by pressing the up arrow" | 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-04 | New pure module `prompt-history.ts` (push/prev/next/entry-at) + global `localStorage['craft-prompt-history']`. Wired into `FreeFormInput.handleKeyDown`: Up recalls when composer empty/recalling, Down walks newer & restores draft, edit/submit/session-switch exit recall. Zero new i18n keys. `data-testid="composer-input"` added for e2e. **DoD:** typecheck:all +0 vs main (11 pre-existing electron errors only); packages/ui clean; `bun test` failure set byte-identical to main (56 pre-existing, verified by stash-diff) + 20 new passing unit tests; renderer build ✅; assertion transpiles. **CDP could not run locally** (egress 403s the Electron binary download, same as prior loop PRs) — assertion included for CI/reviewer. |
3839
| reduce-motion | "Reduce motion" accessibility setting in Appearance | Claude desktop / macOS / Windows "Reduce motion" + `prefers-reduced-motion` | frontend-only | pr-open | [#50](https://github.com/modelstudioai/openwork/issues/50) | [#51](https://github.com/modelstudioai/openwork/pull/51) | (loop) | 2026-07-03 | Reconciled from GitHub: PR open, awaiting review. Not re-selectable. |
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/**
2+
* Feature assertion: the command palette's "Recently used" group.
3+
*
4+
* Drives the real built app over CDP in the draft (no-session) state — no
5+
* seeded conversation and no backend. It proves the full recents lifecycle:
6+
* seed one recent → it renders in a dedicated "Recently used" group →
7+
* a search query hides the group → clearing restores it → running a *new*
8+
* action from the palette records it and, on reopen, it is the top recent.
9+
*
10+
* The reopen step is the load-bearing one: reading the palette after running
11+
* an action proves the palette actually *persists* what you run and surfaces
12+
* it newest-first — not merely that a seeded list can render.
13+
*/
14+
15+
import type { Assertion } from '../runner';
16+
17+
const PALETTE = '[data-testid="command-palette"]';
18+
const INPUT = '[data-testid="command-palette-input"]';
19+
const ITEM = '[data-testid="command-palette-item"]';
20+
const RECENT = '[data-testid="command-palette-recent-item"]';
21+
22+
/** localStorage key the palette reads recents from (shared helper's `craft-` prefix). */
23+
const RECENTS_KEY = 'craft-command-palette-recents';
24+
25+
/** Elements actually visible (cmdk keeps filtered rows in the DOM but hidden). */
26+
function visibleExpr(selector: string): string {
27+
return `[...document.querySelectorAll(${JSON.stringify(selector)})].filter(el => el.offsetParent !== null)`;
28+
}
29+
30+
/** Visible text of each matching row, in DOM order. */
31+
function textsExpr(selector: string): string {
32+
return `${visibleExpr(selector)}.map(el => (el.textContent || '').trim())`;
33+
}
34+
35+
/** Set a controlled input's value the way React/cmdk expect (native setter + input event). */
36+
function setInputExpr(value: string): string {
37+
return `(() => {
38+
const input = document.querySelector(${JSON.stringify(INPUT)});
39+
if (!input) return false;
40+
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
41+
setter.call(input, ${JSON.stringify(value)});
42+
input.dispatchEvent(new Event('input', { bubbles: true }));
43+
return true;
44+
})()`;
45+
}
46+
47+
/** Dispatch the Cmd/Ctrl+K palette hotkey at the window level (both modifiers for cross-platform). */
48+
const OPEN_PALETTE_EXPR = `(() => {
49+
window.dispatchEvent(new KeyboardEvent('keydown', {
50+
key: 'k', code: 'KeyK', ctrlKey: true, metaKey: true, bubbles: true, cancelable: true,
51+
}));
52+
return true;
53+
})()`;
54+
55+
const assertion: Assertion = {
56+
name: 'command palette surfaces and records recently-used actions',
57+
async run(app) {
58+
const { session } = app;
59+
60+
// App must be mounted and at the ready AppShell state.
61+
await session.waitForFunction(
62+
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
63+
{ timeoutMs: 30000, message: 'React UI did not mount' },
64+
);
65+
await session.waitForSelector('[aria-label="Craft menu"]', {
66+
timeoutMs: 30000,
67+
message: 'app did not reach the ready AppShell state',
68+
});
69+
70+
// 1. Seed a single recent action. The palette reads recents from
71+
// localStorage each time it opens, so no reload is needed.
72+
const seeded = await session.evaluate<boolean>(`(() => {
73+
localStorage.setItem(${JSON.stringify(RECENTS_KEY)}, JSON.stringify(['app.toggleTheme']));
74+
return true;
75+
})()`);
76+
if (!seeded) throw new Error('failed to seed command-palette recents');
77+
78+
// 2. Open the palette; the "Recently used" group shows exactly the seeded action.
79+
if (!(await session.evaluate<boolean>(OPEN_PALETTE_EXPR))) {
80+
throw new Error('failed to dispatch command-palette hotkey');
81+
}
82+
await session.waitForSelector(PALETTE, {
83+
timeoutMs: 8000,
84+
message: 'command palette did not open on Ctrl/Cmd+K',
85+
});
86+
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 1`, {
87+
timeoutMs: 5000,
88+
message: 'seeded "Recently used" group did not render exactly one row',
89+
});
90+
const firstRecent = await session.evaluate<string[]>(textsExpr(RECENT));
91+
if (!/toggle theme/i.test(firstRecent[0] || '')) {
92+
throw new Error(`expected seeded recent to be "Toggle Theme", got ${JSON.stringify(firstRecent)}`);
93+
}
94+
95+
// 2b. The recent row renders ABOVE the first category row.
96+
const recentIsFirst = await session.evaluate<boolean>(`(() => {
97+
const recent = ${visibleExpr(RECENT)}[0];
98+
const item = ${visibleExpr(ITEM)}[0];
99+
if (!recent || !item) return false;
100+
return !!(recent.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING);
101+
})()`);
102+
if (!recentIsFirst) {
103+
throw new Error('"Recently used" group did not render above the first category');
104+
}
105+
106+
// 3. Typing a query hides the recents group (recency yields to relevance),
107+
// while normal filtering still works (a matching category row survives).
108+
await session.evaluate(setInputExpr('sidebar'));
109+
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 0`, {
110+
timeoutMs: 5000,
111+
message: 'recents group did not hide while searching',
112+
});
113+
await session.waitForFunction(
114+
`${visibleExpr(ITEM)}.some(el => /toggle sidebar/i.test(el.textContent || ''))`,
115+
{ timeoutMs: 5000, message: 'expected a "Toggle Sidebar" match while filtering by "sidebar"' },
116+
);
117+
118+
// 4. Clearing the query brings the recents group back.
119+
await session.evaluate(setInputExpr(''));
120+
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 1`, {
121+
timeoutMs: 5000,
122+
message: 'recents group did not reappear after clearing the query',
123+
});
124+
125+
// 5. Run a *new* (not-yet-recent) action from the palette: Toggle Sidebar.
126+
const clicked = await session.evaluate<boolean>(`(() => {
127+
const el = ${visibleExpr(ITEM)}.find(el => /toggle sidebar/i.test(el.textContent || ''));
128+
if (!el) return false;
129+
el.click();
130+
return true;
131+
})()`);
132+
if (!clicked) throw new Error('could not click the "Toggle Sidebar" row');
133+
await session.waitForFunction(`!document.querySelector(${JSON.stringify(PALETTE)})`, {
134+
timeoutMs: 5000,
135+
message: 'palette did not close after running an action',
136+
});
137+
138+
// 6. Reopen: the just-run action is now the top recent, ahead of the seed —
139+
// proving the palette recorded it and orders recents newest-first.
140+
if (!(await session.evaluate<boolean>(OPEN_PALETTE_EXPR))) {
141+
throw new Error('failed to reopen the command palette');
142+
}
143+
await session.waitForSelector(PALETTE, {
144+
timeoutMs: 8000,
145+
message: 'command palette did not reopen',
146+
});
147+
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 2`, {
148+
timeoutMs: 5000,
149+
message: 'recents group did not grow to two rows after running a new action',
150+
});
151+
const recents = await session.evaluate<string[]>(textsExpr(RECENT));
152+
if (!/toggle sidebar/i.test(recents[0] || '')) {
153+
throw new Error(`expected "Toggle Sidebar" as the top recent, got ${JSON.stringify(recents)}`);
154+
}
155+
if (!/toggle theme/i.test(recents[1] || '')) {
156+
throw new Error(`expected "Toggle Theme" as the second recent, got ${JSON.stringify(recents)}`);
157+
}
158+
},
159+
};
160+
161+
export default assertion;

packages/shared/src/i18n/locales/de.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@
255255
"chatInput.placeholder.typeMessage": "Nachricht eingeben...",
256256
"chatInput.placeholder.workOn": "Woran möchten Sie arbeiten?",
257257
"commands.searchCommands": "Befehle suchen...",
258+
"commands.recent": "Zuletzt verwendet",
258259
"commands.title": "Befehle",
259260
"common.back": "Zurück",
260261
"common.backToList": "Zurück zur Liste",

0 commit comments

Comments
 (0)