diff --git a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx index bb596ed1d..7256832fc 100644 --- a/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx +++ b/apps/electron/src/renderer/components/app-shell/input/FreeFormInput.tsx @@ -101,6 +101,13 @@ import { addRecentWorkingDir, removeRecentWorkingDir, } from './working-directory-history'; +import { + getPromptHistory, + recordPrompt, + prevPromptIndex, + nextPromptIndex, + promptAt, +} from './prompt-history'; import { CompactPermissionModeSelector } from './CompactPermissionModeSelector'; import { FEATURE_FLAGS } from '@craft-agent/shared/feature-flags'; import { inferFileAttachmentMetadata } from './file-attachment-metadata'; @@ -899,6 +906,33 @@ export function FreeFormInput({ const internalInputRef = React.useRef(null); const richInputRef = externalInputRef || internalInputRef; + // Prompt history recall (Up/Down arrows walk previously-sent prompts, shell-style). + // `promptHistoryIndexRef` is null when not recalling, otherwise the index into + // `promptHistoryRef` currently shown in the composer; `draftBeforeHistoryRef` + // holds the draft the user started from so Down can restore it. + const promptHistoryRef = React.useRef([]); + const promptHistoryIndexRef = React.useRef(null); + const draftBeforeHistoryRef = React.useRef(''); + + // Replace the composer contents programmatically (used by history recall): + // drive the controlled `value` prop and place the caret at the end. + const applyRecalledPrompt = React.useCallback( + (text: string) => { + setInput(text); + syncToParent(text); + requestAnimationFrame(() => { + richInputRef.current?.setSelectionRange(text.length, text.length); + }); + }, + [syncToParent, richInputRef], + ); + + // Reset prompt-history recall when switching sessions. + React.useEffect(() => { + promptHistoryIndexRef.current = null; + draftBeforeHistoryRef.current = ''; + }, [sessionId]); + // Track last caret position for focus restoration (e.g., after permission mode popover closes) const lastCaretPositionRef = React.useRef(null); @@ -1798,6 +1832,10 @@ export function FreeFormInput({ attachments.length > 0 ? attachments : undefined, mentions.skills.length > 0 ? mentions.skills : undefined, ); + // Record the sent prompt for Up/Down recall, and exit any recall mode. + promptHistoryRef.current = recordPrompt(input); + promptHistoryIndexRef.current = null; + draftBeforeHistoryRef.current = ''; setInput(''); setAttachments([]); // Clear draft immediately (cancel any pending debounced sync) @@ -1925,6 +1963,55 @@ export function FreeFormInput({ } } + // Prompt history recall: Up/Down walk previously-sent prompts, shell-style. + // Only when no menu is open and no modifier/IME is active. Up engages when the + // composer is empty (or already recalling); Down only steps while recalling, so + // ordinary caret movement in a draft is untouched. + if ( + (e.key === 'ArrowUp' || e.key === 'ArrowDown') && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + !e.nativeEvent.isComposing && + !inlineMention.isOpen && + !inlineSlash.isOpen && + !inlineLabel.isOpen + ) { + const recalling = promptHistoryIndexRef.current !== null; + if (e.key === 'ArrowUp') { + const currentValue = richInputRef.current?.value ?? ''; + if (recalling || currentValue.trim() === '') { + if (!recalling) { + // Refresh history from storage and remember the draft to restore. + promptHistoryRef.current = getPromptHistory(); + draftBeforeHistoryRef.current = currentValue; + } + const history = promptHistoryRef.current; + const nextIndex = prevPromptIndex(history, promptHistoryIndexRef.current); + const entry = promptAt(history, nextIndex); + if (entry !== null) { + e.preventDefault(); + promptHistoryIndexRef.current = nextIndex; + applyRecalledPrompt(entry); + return; + } + } + } else if (recalling) { + // ArrowDown while recalling: step toward newer, restoring the draft past the end. + e.preventDefault(); + const history = promptHistoryRef.current; + const nextIndex = nextPromptIndex(history, promptHistoryIndexRef.current); + promptHistoryIndexRef.current = nextIndex; + applyRecalledPrompt( + nextIndex === null + ? draftBeforeHistoryRef.current + : promptAt(history, nextIndex) ?? '', + ); + return; + } + } + if ( e.key === 'Tab' && e.shiftKey && @@ -1996,6 +2083,11 @@ export function FreeFormInput({ // Get previous input value before updating state const prevValue = inputRef.current; + // A real edit exits prompt-history recall mode. Programmatic recall updates + // the controlled `value` prop directly (not via this onChange), so this only + // fires for genuine user typing/editing. + promptHistoryIndexRef.current = null; + setInput(nextValue); syncToParent(nextValue); // Debounced sync to parent for draft persistence @@ -2429,6 +2521,7 @@ export function FreeFormInput({ {!(compactMode && isProcessing) && ( { + describe('pushPromptHistory', () => { + it('appends a new prompt to the end (newest last)', () => { + expect(pushPromptHistory(['a', 'b'], 'c')).toEqual(['a', 'b', 'c']) + }) + + it('trims the entry before storing', () => { + expect(pushPromptHistory([], ' hello ')).toEqual(['hello']) + }) + + it('ignores empty / whitespace-only entries', () => { + expect(pushPromptHistory(['a'], '')).toEqual(['a']) + expect(pushPromptHistory(['a'], ' ')).toEqual(['a']) + }) + + it('collapses an immediate repeat of the most recent entry', () => { + expect(pushPromptHistory(['a', 'b'], 'b')).toEqual(['a', 'b']) + // trimming still applies to the repeat check + expect(pushPromptHistory(['a', 'b'], ' b ')).toEqual(['a', 'b']) + }) + + it('keeps a non-consecutive repeat (only immediate repeats collapse)', () => { + expect(pushPromptHistory(['a', 'b'], 'a')).toEqual(['a', 'b', 'a']) + }) + + it('caps the list to MAX_PROMPT_HISTORY, dropping the oldest', () => { + const existing = Array.from({ length: MAX_PROMPT_HISTORY }, (_, i) => `p-${i}`) + const result = pushPromptHistory(existing, 'newest') + expect(result.length).toBe(MAX_PROMPT_HISTORY) + expect(result[result.length - 1]).toBe('newest') + expect(result[0]).toBe('p-1') // 'p-0' was dropped + expect(result).not.toContain('p-0') + }) + + it('does not mutate the input array', () => { + const input = ['a', 'b'] + pushPromptHistory(input, 'c') + expect(input).toEqual(['a', 'b']) + }) + }) + + describe('prevPromptIndex (Up / older)', () => { + const history = ['first', 'second', 'third'] // newest last + + it('starts at the newest entry when not yet recalling', () => { + expect(prevPromptIndex(history, null)).toBe(2) + }) + + it('steps one entry older on each subsequent Up', () => { + expect(prevPromptIndex(history, 2)).toBe(1) + expect(prevPromptIndex(history, 1)).toBe(0) + }) + + it('clamps at the oldest entry', () => { + expect(prevPromptIndex(history, 0)).toBe(0) + }) + + it('returns null when there is no history', () => { + expect(prevPromptIndex([], null)).toBeNull() + }) + }) + + describe('nextPromptIndex (Down / newer)', () => { + const history = ['first', 'second', 'third'] + + it('does nothing when not recalling', () => { + expect(nextPromptIndex(history, null)).toBeNull() + }) + + it('steps one entry newer', () => { + expect(nextPromptIndex(history, 0)).toBe(1) + expect(nextPromptIndex(history, 1)).toBe(2) + }) + + it('returns null (exit recall / restore draft) past the newest entry', () => { + expect(nextPromptIndex(history, 2)).toBeNull() + }) + }) + + describe('promptAt', () => { + const history = ['first', 'second', 'third'] + + it('returns the entry at a valid index', () => { + expect(promptAt(history, 0)).toBe('first') + expect(promptAt(history, 2)).toBe('third') + }) + + it('returns null when not recalling or out of range', () => { + expect(promptAt(history, null)).toBeNull() + expect(promptAt(history, -1)).toBeNull() + expect(promptAt(history, 3)).toBeNull() + }) + }) + + describe('Up/Down round trip', () => { + it('walks back with Up and forward with Down, restoring the draft', () => { + const history = ['one', 'two', 'three'] + // Up, Up, Up + let idx = prevPromptIndex(history, null) + expect(promptAt(history, idx)).toBe('three') + idx = prevPromptIndex(history, idx) + expect(promptAt(history, idx)).toBe('two') + idx = prevPromptIndex(history, idx) + expect(promptAt(history, idx)).toBe('one') + // Down, Down back to newest + idx = nextPromptIndex(history, idx) + expect(promptAt(history, idx)).toBe('two') + idx = nextPromptIndex(history, idx) + expect(promptAt(history, idx)).toBe('three') + // One more Down exits recall (restore the original draft) + idx = nextPromptIndex(history, idx) + expect(idx).toBeNull() + expect(promptAt(history, idx)).toBeNull() + }) + }) + + describe('persistence (getPromptHistory / recordPrompt)', () => { + beforeEach(() => { + const store = new Map() + // Minimal localStorage stub so the storage helper can round-trip in bun. + ;(globalThis as { localStorage?: unknown }).localStorage = { + getItem: (k: string) => (store.has(k) ? store.get(k)! : null), + setItem: (k: string, v: string) => void store.set(k, v), + removeItem: (k: string) => void store.delete(k), + clear: () => store.clear(), + key: () => null, + length: 0, + } + }) + + it('returns an empty list when nothing has been recorded', () => { + expect(getPromptHistory()).toEqual([]) + }) + + it('records prompts and reads them back (newest last)', () => { + recordPrompt('hello') + recordPrompt('world') + expect(getPromptHistory()).toEqual(['hello', 'world']) + }) + + it('applies push semantics (trim + immediate-repeat collapse) when recording', () => { + recordPrompt(' a ') + recordPrompt('a') + expect(getPromptHistory()).toEqual(['a']) + }) + }) +}) diff --git a/apps/electron/src/renderer/components/app-shell/input/prompt-history.ts b/apps/electron/src/renderer/components/app-shell/input/prompt-history.ts new file mode 100644 index 000000000..c0fc33f22 --- /dev/null +++ b/apps/electron/src/renderer/components/app-shell/input/prompt-history.ts @@ -0,0 +1,98 @@ +import * as storage from '@/lib/local-storage' + +/** + * Prompt history for the chat composer. + * + * Mirrors the "recall your previous prompt with the Up arrow" affordance that + * terminal shells, the Claude Code CLI, and the Codex desktop composer all + * provide: pressing Up in an empty (or already-recalling) composer walks + * backwards through the prompts you have sent, and Down walks forward again, + * restoring the draft you started from once you page past the newest entry. + * + * The list is stored newest-**last** (append order) and persisted globally in + * the renderer's localStorage, so history survives restarts and is shared + * across sessions — the same behaviour as a shell's history file. + * + * The navigation model is index-based and deliberately pure so it can be unit + * tested without a DOM: `null` means "not currently recalling", otherwise the + * index points at the entry in `history` currently shown in the composer. + */ + +export const MAX_PROMPT_HISTORY = 100 + +/** + * Append a submitted prompt to the history list. + * - Trims the entry; empty/whitespace-only entries are ignored. + * - Collapses an immediate repeat (same as the most recent entry) so hammering + * the same prompt doesn't bloat the list. + * - Caps the list to {@link MAX_PROMPT_HISTORY}, dropping the oldest entries. + * + * Returns a new array; the input is never mutated. + */ +export function pushPromptHistory( + history: string[], + entry: string, + maxEntries = MAX_PROMPT_HISTORY, +): string[] { + const trimmed = entry.trim() + if (!trimmed) return history + if (history.length > 0 && history[history.length - 1] === trimmed) { + return history + } + const next = [...history, trimmed] + if (next.length > maxEntries) { + return next.slice(next.length - maxEntries) + } + return next +} + +/** + * Index of the entry to show when moving **backwards** (older) with Up. + * - From "not recalling" (`null`), start at the newest entry. + * - Otherwise step one entry older, clamped at the oldest (index 0). + * - Returns `null` when there is no history to recall. + */ +export function prevPromptIndex( + history: string[], + index: number | null, +): number | null { + if (history.length === 0) return null + if (index === null) return history.length - 1 + return Math.max(0, index - 1) +} + +/** + * Index of the entry to show when moving **forwards** (newer) with Down. + * - Only meaningful while recalling (`index` is a number); returns `null` + * otherwise so the caller leaves the composer alone. + * - Stepping past the newest entry returns `null`, signalling "exit recall and + * restore the draft the user started from". + */ +export function nextPromptIndex( + history: string[], + index: number | null, +): number | null { + if (index === null) return null + const next = index + 1 + if (next >= history.length) return null + return next +} + +/** The entry at a navigation index, or `null` when not recalling / out of range. */ +export function promptAt(history: string[], index: number | null): string | null { + if (index === null || index < 0 || index >= history.length) return null + return history[index] +} + +/** Read the persisted prompt history (newest last). */ +export function getPromptHistory(): string[] { + const value = storage.get(storage.KEYS.promptHistory, []) + return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [] +} + +/** Persist a submitted prompt, returning the updated (persisted) list. */ +export function recordPrompt(entry: string): string[] { + const next = pushPromptHistory(getPromptHistory(), entry) + storage.set(storage.KEYS.promptHistory, next) + return next +} diff --git a/apps/electron/src/renderer/lib/local-storage.ts b/apps/electron/src/renderer/lib/local-storage.ts index 8381b07ea..0f4fd641d 100644 --- a/apps/electron/src/renderer/lib/local-storage.ts +++ b/apps/electron/src/renderer/lib/local-storage.ts @@ -41,6 +41,9 @@ export const KEYS = { // Working directory recentWorkingDirs: 'recent-working-dirs', + // Chat composer prompt history (recall previous prompts with Up/Down) + promptHistory: 'prompt-history', + // TurnCard expansion state (persisted across session switches) turnCardExpansion: 'turncard-expansion', diff --git a/docs/loop/feature-ledger.md b/docs/loop/feature-ledger.md index d992798ab..1ff991285 100644 --- a/docs/loop/feature-ledger.md +++ b/docs/loop/feature-ledger.md @@ -32,6 +32,11 @@ log, not the system of record. | slug | title | source | feasibility | status | issue | pr | branch | updated | notes | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 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. | +| 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. | +| composer-expand | Expand / collapse (maximize) toggle for the chat composer | Claude desktop / ChatGPT desktop / 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) | 2026-07-03 | Reconciled from GitHub: PR open, awaiting review. Not re-selectable. | +| scroll-to-bottom | "Jump to latest" (scroll-to-bottom) button in the chat transcript | Claude Code Desktop / ChatGPT desktop / Codex desktop jump-to-latest | frontend-only | pr-open | [#46](https://github.com/modelstudioai/openwork/issues/46) | [#47](https://github.com/modelstudioai/openwork/pull/47) | (loop) | 2026-07-02 | Reconciled from GitHub: PR open, awaiting review. Added a reusable `seed(profileDirs)` e2e hook. Not re-selectable. | +| cmd-f-search-bug | `Cmd+F` / `app.search` shortcut doesn't activate session search | Bug found while shipping command palette | frontend-only (bug) | blocked | [#43](https://github.com/modelstudioai/openwork/issues/43) | — | — | 2026-07-01 | Open bug, needs a seeded session to repro (headless sandbox has none). Tracked, not a feature-loop candidate. | +| 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-04 | **Merged** into `main` (reconciled from GitHub). `thinkingLevel`/`onThinkingLevelChange` were 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. | diff --git a/e2e/assertions/prompt-history.assert.ts b/e2e/assertions/prompt-history.assert.ts new file mode 100644 index 000000000..58963ea8d --- /dev/null +++ b/e2e/assertions/prompt-history.assert.ts @@ -0,0 +1,102 @@ +/** + * Feature assertion: composer prompt-history recall (Up / Down arrows). + * + * Drives the real built app over CDP entirely in the draft (no-session) state — + * no seeded conversation and no backend connection needed. The composer reads + * its history fresh from localStorage, so the assertion seeds three prior + * prompts there and then walks them with the keyboard: + * + * 1. Composer renders and is empty; no history recalled yet. + * 2. Seed localStorage['craft-prompt-history'] = [alpha, beta, gamma]. + * 3. ArrowUp recalls the newest (gamma); further Ups walk older (beta, alpha) + * and clamp at the oldest. + * 4. ArrowDown walks newer again (beta, gamma) and, once past the newest, + * restores the original (empty) draft. + * + * Reading the composer's actual text after each keypress proves the arrows + * really *change the composer contents*, not merely toggle state. + */ + +import type { Assertion } from '../runner'; + +/** The first visible composer contenteditable (there may be several mounted). */ +const COMPOSER_EL = `[...document.querySelectorAll('[data-testid="composer-input"]')].find((el) => el.offsetParent !== null)`; + +/** Current trimmed text of the composer (null if it isn't mounted). */ +const COMPOSER_TEXT_EXPR = `(() => { const el = ${COMPOSER_EL}; return el ? (el.textContent || '').trim() : null; })()`; + +/** Dispatch a real keydown on the composer so its React onKeyDown handler runs. */ +function pressKeyExpr(key: string): string { + return `(() => { + const el = ${COMPOSER_EL}; + if (!el) return false; + el.focus(); + el.dispatchEvent(new KeyboardEvent('keydown', { + key: ${JSON.stringify(key)}, code: ${JSON.stringify(key)}, bubbles: true, cancelable: true, + })); + return true; + })()`; +} + +const SEED = ['alpha one', 'beta two', 'gamma three']; + +const assertion: Assertion = { + name: 'composer Up/Down arrows recall previously-sent prompts', + async run(app) { + const { session } = app; + + // App fully mounted. + await session.waitForFunction( + '!document.getElementById("_loader") && (document.getElementById("root")?.childElementCount ?? 0) > 0', + { timeoutMs: 30000, message: 'React UI did not mount' }, + ); + + // Reach the ready AppShell (not onboarding / workspace picker) — the same + // stable, non-localized anchor the other composer assertions wait on. + await session.waitForSelector('[aria-label="Craft menu"]', { + timeoutMs: 30000, + message: 'app did not reach the ready AppShell state', + }); + + // 1. The composer renders and starts empty. + await session.waitForSelector('[data-testid="composer-input"]', { + timeoutMs: 20000, + message: 'composer input did not render', + }); + await session.waitForFunction(`${COMPOSER_TEXT_EXPR} === ''`, { + timeoutMs: 5000, + message: 'composer did not start empty', + }); + + // 2. Seed prompt history in localStorage (the composer reads it fresh on Up). + const seeded = await session.evaluate(`(() => { + localStorage.setItem('craft-prompt-history', ${JSON.stringify(JSON.stringify(SEED))}); + return true; + })()`); + if (!seeded) throw new Error('failed to seed prompt history in localStorage'); + + // Helper: press a key, then wait for the composer to show the expected text. + const pressExpect = async (key: string, expected: string, note: string) => { + if (!(await session.evaluate(pressKeyExpr(key)))) { + throw new Error(`failed to dispatch ${key} on the composer`); + } + await session.waitForFunction(`${COMPOSER_TEXT_EXPR} === ${JSON.stringify(expected)}`, { + timeoutMs: 5000, + message: `${note}: expected composer text ${JSON.stringify(expected)} after ${key}`, + }); + }; + + // 3. ArrowUp walks backwards through history (newest → oldest), clamping. + await pressExpect('ArrowUp', 'gamma three', 'first Up recalls newest'); + await pressExpect('ArrowUp', 'beta two', 'second Up recalls older'); + await pressExpect('ArrowUp', 'alpha one', 'third Up recalls oldest'); + await pressExpect('ArrowUp', 'alpha one', 'Up clamps at the oldest entry'); + + // 4. ArrowDown walks forward again, then restores the original empty draft. + await pressExpect('ArrowDown', 'beta two', 'Down steps newer'); + await pressExpect('ArrowDown', 'gamma three', 'Down steps to newest'); + await pressExpect('ArrowDown', '', 'Down past newest restores the draft'); + }, +}; + +export default assertion;