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
70 changes: 60 additions & 10 deletions apps/electron/src/renderer/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ import { useRegisterModal } from '@/context/ModalContext'
import {
useAction,
useActionRegistry,
actions as actionDefinitions,
actionsByCategory,
ACTION_LABEL_KEYS,
categoryLabelKey,
type ActionId,
} from '@/actions'
import { readRecents, recordRecent } from './command-palette-recents'

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

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

// Reset the query each time the palette opens so it always starts fresh
// (and the "Recently used" group — shown only for an empty query — is visible).
const handleOpenChange = useCallback((next: boolean) => {
if (next) setSearch('')
setOpen(next)
}, [])

// Participate in the layered modal stack (Cmd+W / X close the topmost modal).
const handleClose = useCallback(() => setOpen(false), [])
useRegisterModal(open, handleClose)

// Turn a runnable action id into a display-ready palette entry.
const toItem = useCallback(
(id: ActionId) => {
const labelKey = ACTION_LABEL_KEYS[id]
return {
id,
label: labelKey ? t(labelKey) : actionDefinitions[id].label,
hotkey: getHotkeyDisplay(id),
}
},
[t, getHotkeyDisplay],
)

// Build the grouped, display-ready action list once.
const groups = useMemo(() => {
return Object.entries(actionsByCategory)
Expand All @@ -75,23 +98,31 @@ export function CommandPalette() {
// is disabled, so the palette never shows a dead entry. Evaluated as
// the palette opens, i.e. against the focus you're returning to.
.filter(action => canExecute(action.id as ActionId))
.map(action => {
const id = action.id as ActionId
const labelKey = ACTION_LABEL_KEYS[id]
return {
id,
label: labelKey ? t(labelKey) : action.label,
hotkey: getHotkeyDisplay(id),
}
}),
.map(action => toItem(action.id as ActionId)),
}))
.filter(group => group.items.length > 0)
// getHotkeyDisplay / t are stable enough for a menu; recompute on open.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])

// "Recently used": the most-recently-run actions, newest first. Kept to
// entries that still exist, aren't excluded from the palette, and can run
// right now — so the group never surfaces a stale or dead command. Only
// shown for an empty query (a search should rank by relevance, not recency).
const recentItems = useMemo(() => {
return readRecents()
.filter((id): id is ActionId => id in actionDefinitions)
.filter(id => !EXCLUDED_ACTIONS.has(id))
.filter(id => canExecute(id))
.map(id => toItem(id))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
const showRecent = search.trim() === '' && recentItems.length > 0

const runAction = useCallback(
(id: ActionId) => {
// Remember this action so it surfaces in "Recently used" next time.
recordRecent(id)
// Close first, then run on the next tick. Closing the dialog restores
// focus to the element that was active before the palette opened, so the
// action runs in the app's real focus context — actions that open a panel
Expand All @@ -104,7 +135,7 @@ export function CommandPalette() {
)

return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
showCloseButton={false}
className="overflow-hidden p-0"
Expand All @@ -121,11 +152,30 @@ export function CommandPalette() {
<CommandInput
data-testid="command-palette-input"
placeholder={t('commands.searchCommands')}
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty data-testid="command-palette-empty">
{t('common.noResultsFound')}
</CommandEmpty>
{showRecent && (
<CommandGroup heading={t('commands.recent')}>
{recentItems.map(item => (
<CommandItem
key={`recent:${item.id}`}
value={`recent ${item.label} ${item.id}`}
data-testid="command-palette-recent-item"
onSelect={() => runAction(item.id)}
>
<span>{item.label}</span>
{item.hotkey && (
<CommandShortcut>{item.hotkey}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
)}
{groups.map(group => (
<CommandGroup key={group.category} heading={group.heading}>
{group.items.map(item => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'bun:test'
import { pushRecent, MAX_RECENTS } from '../command-palette-recents'

describe('pushRecent', () => {
it('prepends a new id as most recent', () => {
expect(pushRecent(['a', 'b'], 'c')).toEqual(['c', 'a', 'b'])
})

it('moves an existing id to the front without duplicating it', () => {
expect(pushRecent(['a', 'b', 'c'], 'c')).toEqual(['c', 'a', 'b'])
expect(pushRecent(['a', 'b', 'c'], 'b')).toEqual(['b', 'a', 'c'])
})

it('is idempotent when re-running the current top action', () => {
expect(pushRecent(['a', 'b'], 'a')).toEqual(['a', 'b'])
})

it('caps the list at the requested size, dropping the oldest', () => {
expect(pushRecent(['a', 'b', 'c'], 'd', 3)).toEqual(['d', 'a', 'b'])
})

it('defaults the cap to MAX_RECENTS', () => {
const many = Array.from({ length: MAX_RECENTS + 3 }, (_, i) => `id-${i}`)
const result = pushRecent(many, 'fresh')
expect(result).toHaveLength(MAX_RECENTS)
expect(result[0]).toBe('fresh')
})

it('does not mutate the input list', () => {
const input = ['a', 'b']
const copy = [...input]
pushRecent(input, 'c')
expect(input).toEqual(copy)
})
})
40 changes: 40 additions & 0 deletions apps/electron/src/renderer/components/command-palette-recents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Command-palette "recently used" history.
*
* A small persisted list of the action IDs most recently run from the command
* palette, newest first. The palette surfaces these in a dedicated "Recently
* used" group at the top so the commands you actually use are one keystroke
* away — the standard command-palette convention (VS Code, Raycast, Linear).
*
* The ordering logic (`pushRecent`) is a pure function so it can be unit-tested
* without a DOM; the read/record wrappers persist through the shared
* localStorage helper.
*/

import { get, set, KEYS } from '@/lib/local-storage'

/** How many recent actions to remember. */
export const MAX_RECENTS = 6

/**
* Return a new recents list with `id` moved to the front (most recent).
*
* Pure: no I/O. Removes any earlier occurrence of `id` (so an action never
* appears twice), prepends it, and caps the list at `cap` entries.
*/
export function pushRecent(list: readonly string[], id: string, cap = MAX_RECENTS): string[] {
const withoutId = list.filter((entry) => entry !== id)
return [id, ...withoutId].slice(0, Math.max(0, cap))
}

/** Read the persisted recent action IDs (newest first). Never throws. */
export function readRecents(): string[] {
const value = get<unknown>(KEYS.commandPaletteRecents, [])
if (!Array.isArray(value)) return []
return value.filter((entry): entry is string => typeof entry === 'string')
}

/** Record `id` as the most recently run action and persist the trimmed list. */
export function recordRecent(id: string): void {
set(KEYS.commandPaletteRecents, pushRecent(readRecents(), id))
}
3 changes: 3 additions & 0 deletions apps/electron/src/renderer/lib/local-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ export const KEYS = {
// Settings navigation
lastSettingsSubpage: 'last-settings-subpage',

// Command palette (most-recently-run action IDs, newest first)
commandPaletteRecents: 'command-palette-recents',

// Appearance
showConnectionIcons: 'show-connection-icons',
reduceMotion: 'reduce-motion', // Minimize animations/transitions app-wide
Expand Down
1 change: 1 addition & 0 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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. |
| 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). |
| 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. |
| 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. |
Expand Down
161 changes: 161 additions & 0 deletions e2e/assertions/command-palette-recents.assert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* Feature assertion: the command palette's "Recently used" group.
*
* Drives the real built app over CDP in the draft (no-session) state — no
* seeded conversation and no backend. It proves the full recents lifecycle:
* seed one recent → it renders in a dedicated "Recently used" group →
* a search query hides the group → clearing restores it → running a *new*
* action from the palette records it and, on reopen, it is the top recent.
*
* The reopen step is the load-bearing one: reading the palette after running
* an action proves the palette actually *persists* what you run and surfaces
* it newest-first — not merely that a seeded list can render.
*/

import type { Assertion } from '../runner';

const PALETTE = '[data-testid="command-palette"]';
const INPUT = '[data-testid="command-palette-input"]';
const ITEM = '[data-testid="command-palette-item"]';
const RECENT = '[data-testid="command-palette-recent-item"]';

/** localStorage key the palette reads recents from (shared helper's `craft-` prefix). */
const RECENTS_KEY = 'craft-command-palette-recents';

/** Elements actually visible (cmdk keeps filtered rows in the DOM but hidden). */
function visibleExpr(selector: string): string {
return `[...document.querySelectorAll(${JSON.stringify(selector)})].filter(el => el.offsetParent !== null)`;
}

/** Visible text of each matching row, in DOM order. */
function textsExpr(selector: string): string {
return `${visibleExpr(selector)}.map(el => (el.textContent || '').trim())`;
}

/** Set a controlled input's value the way React/cmdk expect (native setter + input event). */
function setInputExpr(value: string): string {
return `(() => {
const input = document.querySelector(${JSON.stringify(INPUT)});
if (!input) return false;
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
setter.call(input, ${JSON.stringify(value)});
input.dispatchEvent(new Event('input', { bubbles: true }));
return true;
})()`;
}

/** Dispatch the Cmd/Ctrl+K palette hotkey at the window level (both modifiers for cross-platform). */
const OPEN_PALETTE_EXPR = `(() => {
window.dispatchEvent(new KeyboardEvent('keydown', {
key: 'k', code: 'KeyK', ctrlKey: true, metaKey: true, bubbles: true, cancelable: true,
}));
return true;
})()`;

const assertion: Assertion = {
name: 'command palette surfaces and records recently-used actions',
async run(app) {
const { session } = app;

// App must be mounted and at the ready AppShell state.
await session.waitForFunction(
'!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0',
{ timeoutMs: 30000, message: 'React UI did not mount' },
);
await session.waitForSelector('[aria-label="Craft menu"]', {
timeoutMs: 30000,
message: 'app did not reach the ready AppShell state',
});

// 1. Seed a single recent action. The palette reads recents from
// localStorage each time it opens, so no reload is needed.
const seeded = await session.evaluate<boolean>(`(() => {
localStorage.setItem(${JSON.stringify(RECENTS_KEY)}, JSON.stringify(['app.toggleTheme']));
return true;
})()`);
if (!seeded) throw new Error('failed to seed command-palette recents');

// 2. Open the palette; the "Recently used" group shows exactly the seeded action.
if (!(await session.evaluate<boolean>(OPEN_PALETTE_EXPR))) {
throw new Error('failed to dispatch command-palette hotkey');
}
await session.waitForSelector(PALETTE, {
timeoutMs: 8000,
message: 'command palette did not open on Ctrl/Cmd+K',
});
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 1`, {
timeoutMs: 5000,
message: 'seeded "Recently used" group did not render exactly one row',
});
const firstRecent = await session.evaluate<string[]>(textsExpr(RECENT));
if (!/toggle theme/i.test(firstRecent[0] || '')) {
throw new Error(`expected seeded recent to be "Toggle Theme", got ${JSON.stringify(firstRecent)}`);
}

// 2b. The recent row renders ABOVE the first category row.
const recentIsFirst = await session.evaluate<boolean>(`(() => {
const recent = ${visibleExpr(RECENT)}[0];
const item = ${visibleExpr(ITEM)}[0];
if (!recent || !item) return false;
return !!(recent.compareDocumentPosition(item) & Node.DOCUMENT_POSITION_FOLLOWING);
})()`);
if (!recentIsFirst) {
throw new Error('"Recently used" group did not render above the first category');
}

// 3. Typing a query hides the recents group (recency yields to relevance),
// while normal filtering still works (a matching category row survives).
await session.evaluate(setInputExpr('sidebar'));
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 0`, {
timeoutMs: 5000,
message: 'recents group did not hide while searching',
});
await session.waitForFunction(
`${visibleExpr(ITEM)}.some(el => /toggle sidebar/i.test(el.textContent || ''))`,
{ timeoutMs: 5000, message: 'expected a "Toggle Sidebar" match while filtering by "sidebar"' },
);

// 4. Clearing the query brings the recents group back.
await session.evaluate(setInputExpr(''));
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 1`, {
timeoutMs: 5000,
message: 'recents group did not reappear after clearing the query',
});

// 5. Run a *new* (not-yet-recent) action from the palette: Toggle Sidebar.
const clicked = await session.evaluate<boolean>(`(() => {
const el = ${visibleExpr(ITEM)}.find(el => /toggle sidebar/i.test(el.textContent || ''));
if (!el) return false;
el.click();
return true;
})()`);
if (!clicked) throw new Error('could not click the "Toggle Sidebar" row');
await session.waitForFunction(`!document.querySelector(${JSON.stringify(PALETTE)})`, {
timeoutMs: 5000,
message: 'palette did not close after running an action',
});

// 6. Reopen: the just-run action is now the top recent, ahead of the seed —
// proving the palette recorded it and orders recents newest-first.
if (!(await session.evaluate<boolean>(OPEN_PALETTE_EXPR))) {
throw new Error('failed to reopen the command palette');
}
await session.waitForSelector(PALETTE, {
timeoutMs: 8000,
message: 'command palette did not reopen',
});
await session.waitForFunction(`${visibleExpr(RECENT)}.length === 2`, {
timeoutMs: 5000,
message: 'recents group did not grow to two rows after running a new action',
});
const recents = await session.evaluate<string[]>(textsExpr(RECENT));
if (!/toggle sidebar/i.test(recents[0] || '')) {
throw new Error(`expected "Toggle Sidebar" as the top recent, got ${JSON.stringify(recents)}`);
}
if (!/toggle theme/i.test(recents[1] || '')) {
throw new Error(`expected "Toggle Theme" as the second recent, got ${JSON.stringify(recents)}`);
}
},
};

export default assertion;
1 change: 1 addition & 0 deletions packages/shared/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@
"chatInput.placeholder.typeMessage": "Nachricht eingeben...",
"chatInput.placeholder.workOn": "Woran möchten Sie arbeiten?",
"commands.searchCommands": "Befehle suchen...",
"commands.recent": "Zuletzt verwendet",
"commands.title": "Befehle",
"common.back": "Zurück",
"common.backToList": "Zurück zur Liste",
Expand Down
Loading