Skip to content
Open
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 @@ -50,6 +50,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
12 changes: 8 additions & 4 deletions docs/loop/feature-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ log, not the system of record.

| slug | title | source | feasibility | status | issue | pr | branch | updated | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 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/reduce-motion | 2026-07-03 | Renderer-only pref (localStorage) applied app-wide via `<MotionConfig reducedMotion>` + `data-reduce-motion` on `<html>` + global CSS guard. Off ⇒ `reducedMotion="user"` (still honors OS). New `ReduceMotionProvider` in `main.tsx`; toggle in Appearance→Interface; 2 new i18n keys ×7 locales. typecheck/`bun test` zero-delta vs main (56-failure set byte-identical); renderer build ✅; i18n parity ✅. CDP assertion included; **could not run locally** (egress 403s Electron binary download). |
| 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-03 | Opened by a prior run. Adds `isComposerExpanded` toggle in `FreeFormInput`; 2 new i18n keys. Awaiting review. |
| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code / 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-02 | Opened by a prior run. Floating jump button in `ChatDisplay` + `seed()` harness hook. Awaiting review. |
| 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-03 | **Merged** into `main` (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). |
| 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 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. |
| 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. |
| 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`. |
| 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. |
| 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. |
| 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. |
| 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). |
| 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. |
| 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. |
Loading