From 3449855dab4c68db0fb5eacccf6bfe81f39ec329 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:15:26 +0200 Subject: [PATCH] feat(app): add searchable prompt history picker (#57716) --- packages/app/component-tests/composer.spec.ts | 305 ++++++++++ .../app/src/composer/composer.stories.tsx | 61 +- packages/app/src/composer/editor/editor.css | 8 +- packages/app/src/composer/editor/editor.tsx | 551 ++++++++++++------ .../app/src/composer/editor/interaction.ts | 191 +++++- packages/app/src/composer/model.ts | 9 + .../app/src/composer/suggestions/machine.ts | 75 ++- packages/app/src/composer/types.ts | 3 +- packages/app/src/shell/commands/dialog.css | 81 --- packages/app/src/shell/commands/dialog.tsx | 12 +- packages/app/src/shell/palette-menu.css | 88 +++ packages/ui/src/i18n/en.ts | 4 + 12 files changed, 1079 insertions(+), 309 deletions(-) create mode 100644 packages/app/src/shell/palette-menu.css diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index c4b2e21e45c..9bbb058f6c9 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -1,5 +1,310 @@ import { expect, story } from "../../storybook/playwright/story" +story("cycles full prompts with plain arrows without opening the picker", async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--prompt-history") + const input = component.getByRole("textbox", { name: "Prompt", exact: true }) + const options = component.getByRole("option") + const composer = component.locator('[data-component="composer"]') + const content = component.locator('[data-slot="composer-content"]') + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + const height = await composer.evaluate((element) => element.getBoundingClientRect().height) + + await input.press("ArrowUp") + await expect(input).toHaveText("Explain the latest changes") + await expect(input).toBeFocused() + await expect(options).toHaveCount(0) + await expect(content).toHaveCSS("mask-image", "none") + await input.press("ArrowUp") + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible() + await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible() + await expect + .poll(() => composer.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(height) + await expect(options).toHaveCount(0) + await input.press("ArrowUp") + await expect(input).toHaveText("Run the focused tests") + await input.press("ArrowUp") + await expect(input).toHaveText("Run the focused tests") + await input.press("ArrowDown") + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await input.press("ArrowDown") + await expect(input).toHaveText("Explain the latest changes") + await input.press("ArrowDown") + await expect(input).toBeEmpty() + await expect(composer).toHaveCSS("height", `${height}px`) + + // A new cycle saves and restores an empty-text draft's attachments and comments. + await input.press("ArrowUp") + await input.press("ArrowUp") + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await input.fill("") + await input.press("ArrowUp") + await expect(input).toHaveText("Explain the latest changes") + await expect(component.getByRole("img", { name: "layout.png", exact: true })).toHaveCount(0) + await input.press("ArrowDown") + await expect(input).toBeEmpty() + await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible() + await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible() + + await input.press("ArrowUp") + await input.press("ArrowUp") + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await input.press("ArrowRight") + await input.press("Shift+ArrowUp") + await expect(options).toHaveCount(0) + await expect.poll(() => input.evaluate(() => window.getSelection()?.toString())).toBe("R") + await input.press("ArrowRight") + await input.pressSequentially("!") + await expect(input).toHaveText("R!eview the composer layout\nKeep keyboard navigation accessible") + await input.press("ArrowUp") + await expect(input).toHaveText("R!eview the composer layout\nKeep keyboard navigation accessible") + + await input.fill("") + await input.press("ArrowUp") + await expect(input).toHaveText("Explain the latest changes") + await input.press("Enter") + await expect(input).toBeEmpty() + await expect(component.getByRole("status")).toHaveText("Submitted: Explain the latest changes") + await input.press("ArrowUp") + await expect(input).toHaveText("Explain the latest changes") + await expect(options).toHaveCount(0) + + await input.fill("!") + await expect(input).toBeEmpty() + await input.press("ArrowUp") + await expect(input).toHaveText("bun typecheck") + await expect(options).toHaveCount(0) + await input.press("ArrowDown") + await expect(input).toBeEmpty() + await input.press("ArrowUp") + await input.press("Enter") + await expect(component.getByRole("status")).toHaveText("Submitted: bun typecheck") +}) + +story( + "previews history in the composer, accepts without sending, and cancels back to the original draft", + async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--prompt-history") + const input = component.getByRole("textbox", { name: "Prompt", exact: true }) + const search = component.getByRole("combobox", { name: "Prompt history", exact: true }) + const options = component.getByRole("option") + const composer = component.locator('[data-component="composer"]') + const content = component.locator('[data-slot="composer-content"]') + const viewport = component.locator('[data-component="composer-scroll"] > .scroll-view__viewport') + await page.evaluate(() => document.fonts.ready.then(() => undefined)) + const height = await composer.evaluate((element) => element.getBoundingClientRect().height) + + await input.press("Shift+ArrowUp") + await expect(input).toBeFocused() + await expect(search).toHaveCount(0) + await expect(input).toHaveText("Explain the latest changes") + await expect(composer).toHaveCSS("height", `${height}px`) + const menu = await component.locator('[data-component="composer-suggestions"]').boundingBox() + + if (!menu) throw new Error("Missing history menu") + await expect(options).toHaveText([ + "Run the focused tests", + "Review the composer layout Keep keyboard navigation accessible", + "Explain the latest changes", + ]) + await expect(component.getByRole("option", { selected: true })).toHaveText("Explain the latest changes") + await input.press("ArrowUp") + await expect(component.getByRole("option", { selected: true })).toHaveText( + "Review the composer layout Keep keyboard navigation accessible", + ) + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await expect(composer).toHaveCSS("height", `${height}px`) + await expect + .poll(() => component.locator('[data-slot="composer-content"]').evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0) + await expect(input).toBeInViewport() + await expect + .poll(() => + input.evaluate( + (element) => + element.getBoundingClientRect().top - + element.closest('[data-slot="composer-content"]')!.getBoundingClientRect().top, + ), + ) + .toBe(0) + await expect(content).not.toHaveCSS("mask-image", "none") + await expect(viewport).toHaveCSS("mask-image", "none") + await expect(content).toHaveCSS("--composer-fade-bottom", "16px") + await content.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await expect(content).toHaveCSS("--composer-fade-bottom", "0px") + await expect + .poll(async () => (await component.locator('[data-component="composer-suggestions"]').boundingBox())?.y) + .toBe(menu.y) + await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible() + await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible() + await input.press("ArrowUp") + await expect(input).toHaveText("Run the focused tests") + await expect(composer).toHaveCSS("height", `${height}px`) + await input.press("ArrowUp") + await expect(input).toHaveText("Run the focused tests") + await input.press("ArrowDown") + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await input.press("ControlOrMeta+f") + await expect(search).toBeFocused() + await search.fill("ACCESSIBLE") + await expect(composer).toHaveCSS("height", `${height}px`) + await search.press("Escape") + await expect(input).toBeFocused() + await expect(composer).toHaveCSS("height", `${height}px`) + await expect + .poll(() => + component + .locator('[data-slot="composer-content"]') + .evaluate((element) => element.scrollHeight > element.clientHeight), + ) + .toBe(true) + await input.press("Enter") + await expect(options).toHaveCount(0) + await expect(content).toHaveCSS("mask-image", "none") + await expect(viewport).not.toHaveCSS("mask-image", "none") + await expect(content).toHaveCSS("--composer-fade-bottom", "0px") + await expect(input).toBeFocused() + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await expect(component.getByRole("status")).toHaveText("Ready") + await expect + .poll(() => composer.evaluate((element) => element.getBoundingClientRect().height)) + .toBeGreaterThan(height) + + // History must not intercept navigation in a populated multiline editor. + await input.press("ControlOrMeta+Home") + await input.press("ArrowUp") + await expect(search).toHaveCount(0) + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + + // An empty-text draft can still have attachments and comments. Previewing must not lose them on cancel. + await input.fill("") + const restoredHeight = await composer.evaluate((element) => element.getBoundingClientRect().height) + await input.press("Shift+ArrowUp") + await expect(input).toHaveText("Explain the latest changes") + await expect(component.getByRole("img", { name: "layout.png", exact: true })).toHaveCount(0) + await expect(composer).toHaveCSS("height", `${restoredHeight}px`) + await input.press("Escape") + await expect(options).toHaveCount(0) + await expect(input).toBeFocused() + await expect(input).toBeEmpty() + await expect(composer).toHaveCSS("height", `${restoredHeight}px`) + await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible() + await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible() + }, +) + +story("searches history on demand, returns to browsing, and keeps shell history separate", async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--prompt-history") + const input = component.getByRole("textbox", { name: "Prompt", exact: true }) + const search = component.getByRole("combobox", { name: "Prompt history", exact: true }) + + await input.press("Shift+ArrowUp") + await input.press("ControlOrMeta+f") + await expect(search).toBeFocused() + await search.fill("ACCESSIBLE") + await expect(component.getByRole("option")).toHaveCount(1) + await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") + await expect(search).toBeFocused() + await search.press("Escape") + await expect(search).toHaveCount(0) + await expect(input).toBeFocused() + await expect(component.getByRole("option")).toHaveCount(3) + await input.press("Escape") + await expect(component.getByRole("option")).toHaveCount(0) + await expect(input).toBeEmpty() + + await input.press("Shift+ArrowUp") + await component.getByRole("button", { name: /^Search/ }).click() + await expect(search).toBeFocused() + await search.fill("no matching prompt") + await expect(component.getByRole("option")).toHaveCount(0) + await expect(component.getByText("No matching items", { exact: true })).toBeVisible() + await search.press("ArrowUp") + await search.press("Enter") + await expect(search).toHaveValue("no matching prompt") + await expect(input).toHaveText("Explain the latest changes") + await search.fill("tests") + await expect(input).toHaveText("Run the focused tests") + await expect(search).toBeFocused() + await search.press("Enter") + await expect(search).toHaveCount(0) + await expect(input).toBeFocused() + await expect(component.getByRole("status")).toHaveText("Ready") + + await input.fill("") + await input.press("Shift+ArrowUp") + await component.getByRole("option", { name: "Run the focused tests", exact: true }).click() + await expect(input).toHaveText("Run the focused tests") + await expect(input).toBeFocused() + await expect(component.getByRole("status")).toHaveText("Ready") + + await page.mouse.move(0, 0) + await input.fill("") + await input.press("Shift+ArrowUp") + await expect(input).toHaveText("Explain the latest changes") + await expect(component.getByRole("option")).toHaveCount(3) + await expect(input).toBeFocused() + await input.press("Shift+Digit1") + await expect(component.getByRole("option")).toHaveCount(0) + await expect(input).toHaveText("Explain the latest changes!") + + await input.fill("!") + await expect(input).toBeEmpty() + await input.press("Shift+ArrowUp") + await expect(input).toHaveText("bun typecheck") + await expect(component.getByRole("option")).toHaveText(["bun typecheck"]) + await input.press("Escape") + await expect(input).toBeFocused() + await expect(input).toBeEmpty() + await input.press("Shift+ArrowUp") + await input.press("Enter") + await expect(input).toHaveText("bun typecheck") + await expect(component.getByRole("status")).toHaveText("Ready") +}) + +story("pins history search above the scrolling prompts", async ({ mount }) => { + const component = await mount("opencode-composer-flow--prompt-history", { args: { many: true } }) + const input = component.getByRole("textbox", { name: "Prompt", exact: true }) + await input.press("Shift+ArrowUp") + const menu = component.locator('[data-component="composer-suggestions"]') + const search = menu.getByRole("button", { name: /^Search/ }) + const viewport = menu.getByRole("region", { name: "scrollable content" }) + await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) + + const clippedTop = () => + viewport.evaluate((element) => { + const top = element.getBoundingClientRect().top + + return Array.from(element.querySelectorAll('[role="option"]')).some((row) => { + const rect = row.getBoundingClientRect() + + return rect.top < top && rect.bottom > top + }) + }) + + await expect.poll(clippedTop).toBe(false) + + for (let index = 0; index < 12; index++) await input.press("ArrowUp") + await expect(menu.getByRole("option", { selected: true })).toBeInViewport({ ratio: 1 }) + await expect.poll(clippedTop).toBe(false) + await expect.poll(async () => (await search.boundingBox())!.y - (await viewport.boundingBox())!.y).toBeLessThan(0) + const top = (await search.boundingBox())!.y + + await viewport.evaluate((element) => { + element.scrollTop = 0 + }) + await expect(search).toBeInViewport() + expect((await search.boundingBox())!.y).toBe(top) + await viewport.evaluate((element) => { + element.scrollTop = element.scrollHeight + }) + await expect(search).toBeInViewport() + expect((await search.boundingBox())!.y).toBe(top) +}) + for (const direction of ["ltr", "rtl"]) { for (const alternate of ["none", "queue", "steer"]) { story(`scrolls overflowing controls beside fixed ${alternate} actions in ${direction}`, async ({ mount, page }) => { diff --git a/packages/app/src/composer/composer.stories.tsx b/packages/app/src/composer/composer.stories.tsx index 9de88cc01a4..9be5af3997f 100644 --- a/packages/app/src/composer/composer.stories.tsx +++ b/packages/app/src/composer/composer.stories.tsx @@ -60,6 +60,8 @@ function ComposerStory(props: { longLabels?: boolean alternate?: "queue" | "steer" manySuggestions?: boolean + history?: boolean + manyHistory?: boolean suggestionBoundary?: () => HTMLElement | undefined }) { const [draft, setDraft] = createStore({ @@ -149,6 +151,51 @@ function ComposerStory(props: { commands: () => commands, context: () => context, searchContextFiles: () => [], + history: props.history + ? { + entries: (mode) => + mode === "shell" + ? [{ prompt: text("bun typecheck") }] + : [ + { prompt: text("Explain the latest changes") }, + { + prompt: [ + ...text("Review the composer layout\nKeep keyboard navigation accessible"), + { + type: "path", + id: "history-layout", + filename: "layout.png", + mime: "image/png", + path: "C:/shots/layout.png", + }, + ], + metadata: "review", + }, + { prompt: text("Run the focused tests") }, + ...(props.manyHistory + ? Array.from({ length: 16 }, (_, index) => ({ prompt: text(`Earlier prompt ${index + 1}`) })) + : []), + ], + add() {}, + capture: () => (draft.context.items.some((item) => item.key === "history-comment") ? "review" : undefined), + restore: (metadata) => + setDraft( + "context", + "items", + metadata === "review" + ? [ + { + type: "file", + key: "history-comment", + path: "src/composer.tsx", + selection: { startLine: 12, startChar: 0, endLine: 14, endChar: 0 }, + comment: "Keep the search separate from the draft", + }, + ] + : [], + ), + } + : undefined, readImage: async () => pixel, view: { placeholder: () => "Ask anything, / for commands, @ for context...", @@ -231,7 +278,10 @@ function ComposerStory(props: { }) return ( -
+
{story.activity} @@ -252,6 +302,15 @@ export default { export const EmptyDraft = { render: () => } +export const PromptHistory = { + args: { many: false }, + render: (args: { many: boolean }) => ( +
+ +
+ ), +} + export const TextDraft = { render: () => } export const MultilineDraft = { diff --git a/packages/app/src/composer/editor/editor.css b/packages/app/src/composer/editor/editor.css index 6c663884bb2..4188655c89d 100644 --- a/packages/app/src/composer/editor/editor.css +++ b/packages/app/src/composer/editor/editor.css @@ -12,7 +12,8 @@ /* Matches the 16px session title fade. The top fade only covers the editor's 16px top padding until text scrolls under it; the mask sits on the viewport so the thumb stays solid. */ -[data-component="composer-scroll"] > .scroll-view__viewport { +[data-component="composer-scroll"] > .scroll-view__viewport, +[data-slot="composer-content"][data-history-preview] { mask-image: linear-gradient( to bottom, transparent, @@ -22,6 +23,11 @@ ); } +/* History scrolls text and attachments together, so only the outer viewport applies the mask. */ +[data-slot="composer-content"][data-history-preview] [data-component="composer-scroll"] > .scroll-view__viewport { + mask-image: none; +} + /* Masks only cover hidden content; they do not reserve space in the toolbar. */ [data-slot="composer-controls"][data-overflow-start="true"] { --mask-start: 16px; diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index b486017dfe1..3db133d788f 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -3,6 +3,7 @@ import { createMemo, createResource, createSignal, + createUniqueId, For, onCleanup, onMount, @@ -45,8 +46,10 @@ import type { import type { ComposerEditorModel, ComposerSelectControl } from "./interaction" import { isAttachment } from "../prompt-parts" import { QuoteCommentEditor } from "./quote-comment" +import { formatKeybindParts } from "@/shell/commands/command" import "../attachments/attachments.css" import "./editor.css" +import "@/shell/palette-menu.css" const MobilePanelDrawer = lazy(async () => { const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer") @@ -95,11 +98,35 @@ export function ComposerEditor(props: ComposerEditorProps) { const i18n = useI18n() const state = props.controller.state const view = props.controller.view + const suggestionsID = createUniqueId() + let composer: HTMLFormElement | undefined + let content: HTMLDivElement | undefined let editor: HTMLDivElement | undefined let viewport: HTMLDivElement | undefined let controlsViewport!: HTMLDivElement let controlsContent!: HTMLDivElement const [overflow, setOverflow] = createStore({ start: false, end: false }) + const [historyLayout, setHistoryLayout] = createStore<{ height?: number }>({}) + + createEffect(() => { + if (state.popover.type !== "history") setHistoryLayout("height", undefined) + }) + + createEffect(() => { + const preview = historyLayout.height !== undefined + + if (preview) props.controller.parts() + + // Show the beginning of each prompt; attachments remain accessible above it in the same viewport. + const frame = requestAnimationFrame(() => { + if (preview && content && editor) + content.scrollTop += editor.getBoundingClientRect().top - content.getBoundingClientRect().top + + updateScroll() + }) + + onCleanup(() => cancelAnimationFrame(frame)) + }) const updateOverflow = () => { const offset = Math.abs(controlsViewport.scrollLeft) @@ -113,8 +140,10 @@ export function ComposerEditor(props: ComposerEditorProps) { const [fadeBottom, setFadeBottom] = createSignal(0) const updateScroll = () => { - if (!viewport) return - setFadeBottom(Math.min(16, Math.max(0, viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop))) + const scroller = historyLayout.height !== undefined ? content : viewport + + if (!scroller) return + setFadeBottom(Math.min(16, Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop))) } onMount(() => { @@ -162,16 +191,19 @@ export function ComposerEditor(props: ComposerEditorProps) { // Kept out of JSX: an inline ternary prop compiles to a memo created on every read, and the popover reads // search from a ResizeObserver callback, where that memo has no owner and is never disposed. - const search = () => - state.popover.type === "command-menu" - ? { - value: state.popover.query, - label: i18n.t("ui.promptInput.commands"), - placeholder: "/", - onValueChange: props.controller.setQuery, - onKeyDown: props.controller.onKeyDown, - } - : undefined + const search = () => { + if (state.popover.type !== "history" && state.popover.type !== "command-menu") return + + if (state.popover.type === "history" && !state.popover.searching) return + + return { + value: state.popover.query, + label: i18n.t(state.popover.type === "history" ? "ui.promptInput.history" : "ui.promptInput.commands"), + placeholder: state.popover.type === "history" ? i18n.t("ui.promptInput.history.search") : "/", + onValueChange: props.controller.setQuery, + onKeyDown: props.controller.onKeyDown, + } + } return (
@@ -189,21 +221,30 @@ export function ComposerEditor(props: ComposerEditorProps) { /> editor} + onSearch={() => props.controller.dispatch({ type: "history.search" })} + onClose={() => props.controller.dispatch({ type: "popover.close" })} onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })} onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })} />
{ event.preventDefault() @@ -215,123 +256,153 @@ export function ComposerEditor(props: ComposerEditorProps) { onDragLeave={props.controller.onDragLeave} onDrop={props.controller.onDrop} > - - props.controller.removeAttachment(attachment.id)} - onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)} - onCommentClick={(comment) => props.controller.toggleContext(comment.key)} - onCommentRemove={(comment) => props.controller.removeContext(comment.key)} - onFileRemove={(file) => props.controller.removeContext(file.key)} - editingQuote={props.controller.editingQuote()} - onEditQuote={props.controller.editQuote} - onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)} - searchPaths={props.controller.searchPaths} - /> - - - { - viewport = element - element.tabIndex = -1 - }} > -
{ - editor = element - props.controller.setEditor(element) - }} - data-component="composer-editor" - role="textbox" - aria-multiline="true" - aria-label={i18n.t("ui.promptInput.label")} - dir={state.mode === "normal" ? "auto" : "ltr"} - contenteditable={!props.disabled && !props.readOnly} - autocapitalize={state.mode === "normal" ? "sentences" : "off"} - autocorrect={state.mode === "normal" ? "on" : "off"} - spellcheck={state.mode === "normal"} - // @ts-expect-error - autocomplete="off" - class="relative z-10 block w-full whitespace-pre-wrap bg-transparent px-3 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" - classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell", "opacity-50": props.disabled }} - style={{ - "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, - "text-align": "start", - }} - onInput={(event) => { - const cursor = composerCursor(event.currentTarget) - const prompt = parseComposerEditor(event.currentTarget) - const attachments = props.controller.parts().filter(isAttachment) - localInput = true - props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...attachments], cursor) - }} - onKeyDown={(event) => { - if (!view.draftOnly && props.controller.onKeyDown(event)) return - const mod = event.metaKey || event.ctrlKey - - if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) { - if (view.submit.queue?.editFirst()) event.preventDefault() - - return - } - - if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { - event.preventDefault() - - if (event.repeat) return - props.controller.submit(mod ? { alternate: true } : undefined) - } - }} - onKeyUp={updateCursor} - onPointerUp={updateCursor} - onPaste={(event) => { - props.controller.onPaste(event) - // Programmatic multiline insertion does not reliably reveal the caret. - requestAnimationFrame(() => { - const selection = window.getSelection() - - if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return - - if (!editor.contains(selection.anchorNode)) return - const caret = selection.getRangeAt(0).getBoundingClientRect() - - if (!caret.height) return - const bounds = viewport.getBoundingClientRect() - - if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8 - - if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8 - }) - }} - onFocus={() => props.controller.dispatch({ type: "focus.editor" })} - /> - -
- {view.placeholder?.() ?? - (state.mode === "shell" - ? i18n.t("ui.promptInput.placeholder.shell") - : i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))} -
+ + props.controller.removeAttachment(attachment.id)} + onUploadCancel={(upload) => props.controller.cancelUpload(upload.id)} + onCommentClick={(comment) => props.controller.toggleContext(comment.key)} + onCommentRemove={(comment) => props.controller.removeContext(comment.key)} + onFileRemove={(file) => props.controller.removeContext(file.key)} + editingQuote={props.controller.editingQuote()} + onEditQuote={props.controller.editQuote} + onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)} + searchPaths={props.controller.searchPaths} + /> - -
+ { + viewport = element + element.tabIndex = -1 + }} + > +
{ + editor = element + props.controller.setEditor(element) + }} + data-component="composer-editor" + role="textbox" + aria-multiline="true" + aria-label={i18n.t("ui.promptInput.label")} + aria-controls={state.popover.type === "history" ? suggestionsID : undefined} + aria-activedescendant={ + state.popover.type === "history" && !state.popover.searching && state.popover.activeID + ? `${suggestionsID}-${state.popover.activeID}` + : undefined + } + dir={state.mode === "normal" ? "auto" : "ltr"} + contenteditable={!props.disabled && !props.readOnly} + autocapitalize={state.mode === "normal" ? "sentences" : "off"} + autocorrect={state.mode === "normal" ? "on" : "off"} + spellcheck={state.mode === "normal"} + // @ts-expect-error + autocomplete="off" + class="relative z-10 block w-full whitespace-pre-wrap bg-transparent px-3 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword" + classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell", "opacity-50": props.disabled }} + style={{ + "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, + "text-align": "start", + }} + onInput={(event) => { + const cursor = composerCursor(event.currentTarget) + const prompt = parseComposerEditor(event.currentTarget) + const attachments = props.controller.parts().filter(isAttachment) + localInput = true + props.controller.onInput( + prompt.map((part) => part.content).join(""), + [...prompt, ...attachments], + cursor, + ) + }} + onKeyDown={(event) => { + // Capture the opening footprint before the controller applies its first history preview. + const height = + state.popover.type === "closed" && event.shiftKey && event.key === "ArrowUp" + ? composer?.getBoundingClientRect().height + : undefined + + if (!view.draftOnly && props.controller.onKeyDown(event)) { + if (height !== undefined && state.popover.type === "history") setHistoryLayout("height", height) + + return + } + + const mod = event.metaKey || event.ctrlKey + + if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) { + if (view.submit.queue?.editFirst()) event.preventDefault() + + return + } + + if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { + event.preventDefault() + + if (event.repeat) return + props.controller.submit(mod ? { alternate: true } : undefined) + } + }} + onKeyUp={updateCursor} + onPointerUp={updateCursor} + onPaste={(event) => { + props.controller.onPaste(event) + // Programmatic multiline insertion does not reliably reveal the caret. + requestAnimationFrame(() => { + const selection = window.getSelection() + + if (!editor || !viewport || !selection?.isCollapsed || !selection.rangeCount) return + + if (!editor.contains(selection.anchorNode)) return + const caret = selection.getRangeAt(0).getBoundingClientRect() + + if (!caret.height) return + const bounds = viewport.getBoundingClientRect() + + if (caret.bottom > bounds.bottom - 8) viewport.scrollTop += caret.bottom - bounds.bottom + 8 + + if (caret.top < bounds.top + 8) viewport.scrollTop += caret.top - bounds.top - 8 + }) + }} + onFocus={() => props.controller.dispatch({ type: "focus.editor" })} + /> + +
+ {view.placeholder?.() ?? + (state.mode === "shell" + ? i18n.t("ui.promptInput.placeholder.shell") + : i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))} +
+
+ +
+ +
HTMLElement | undefined + onSearch?: () => void + onClose?: () => void search?: { value: string label: string @@ -1106,10 +1183,44 @@ export function ComposerEditorPopover(props: { onActiveChange: (item: ComposerSuggestion) => void onSelect: (item: ComposerSuggestion) => void }) { + const i18n = useI18n() + const listID = props.id ?? createUniqueId() + let container: HTMLDivElement | undefined const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT }) const resize = (height: number) => - setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined)) + setStore( + "maxHeight", + composerSuggestionMaxHeight(height, props.search !== undefined || !!props.history, !!props.history), + ) + + createEffect(() => { + if (!props.history || !props.activeID) return + + const frame = requestAnimationFrame(() => + container?.querySelector("[data-active]")?.scrollIntoView({ block: "nearest" }), + ) + + onCleanup(() => cancelAnimationFrame(frame)) + }) + + createEffect(() => { + if (!props.history) return + + const dismiss = (event: Event) => { + if (!(event.target instanceof Node) || container?.contains(event.target)) return + + if (event.type === "focusin" && props.historyBrowsing && props.editor?.()?.contains(event.target)) return + props.onClose?.() + } + + document.addEventListener("pointerdown", dismiss) + document.addEventListener("focusin", dismiss) + onCleanup(() => { + document.removeEventListener("pointerdown", dismiss) + document.removeEventListener("focusin", dismiss) + }) + }) // A detached boundary (e.g. the previous session's timeline while the next one loads) measures 0px. const boundary = () => { @@ -1123,75 +1234,155 @@ export function ComposerEditorPopover(props: { return (
event.preventDefault()} > - - {(search) => ( -
- requestAnimationFrame(() => element.focus())} - value={search().value} - aria-label={search().label} - placeholder={search().placeholder} - class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" - onInput={(event) => search().onValueChange(event.currentTarget.value)} - onKeyDown={(event) => search().onKeyDown(event)} - onMouseDown={(event) => event.stopPropagation()} - /> -
- )} -
- 0} - fallback={
{props.emptyLabel}
} - > - - {(item) => ( - + + + {(search) => ( +
+ requestAnimationFrame(() => element.isConnected && element.focus())} + value={search().value} + aria-label={search().label} + placeholder={search().placeholder} + autocomplete="off" + spellcheck={false} + class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + role="combobox" + aria-expanded="true" + aria-autocomplete="list" + aria-controls={listID} + aria-activedescendant={props.activeID ? `${listID}-${props.activeID}` : undefined} + onInput={(event) => search().onValueChange(event.currentTarget.value)} + onKeyDown={(event) => search().onKeyDown(event)} + onMouseDown={(event) => event.stopPropagation()} + /> +
)} -
+
+ + + + +
+ {props.emptyLabel}
} + > + + {(item) => ( + + )} + + +
+ + + + + {(search) => ( +
+ requestAnimationFrame(() => element.focus())} + value={search().value} + aria-label={search().label} + placeholder={search().placeholder} + class="w-full bg-transparent text-[13px] leading-5 text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => search().onValueChange(event.currentTarget.value)} + onKeyDown={(event) => search().onKeyDown(event)} + onMouseDown={(event) => event.stopPropagation()} + /> +
+ )} +
+ 0} + fallback={
{props.emptyLabel}
} + > + + {(item) => ( + + )} + +
) } -function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) { +function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean, history = false) { const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4) const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve) - const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0) + // History has padding on both edges and shows whole rows below the pinned search control. + const chrome = COMPOSER_SUGGESTION_TOP_PADDING * (history ? 2 : 1) + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0) + const row = COMPOSER_SUGGESTION_ROW_HEIGHT + const peek = history ? 0 : COMPOSER_SUGGESTION_ROW_PEEK - if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit + if (limit < chrome + row + peek) return limit - return ( - chrome + - Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) * - COMPOSER_SUGGESTION_ROW_HEIGHT + - COMPOSER_SUGGESTION_ROW_PEEK - ) + return chrome + Math.floor((limit - chrome - peek) / row) * row + peek } // "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index 38a15f00a08..f0df04b0070 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -1,5 +1,5 @@ import { Predicate } from "effect" -import { createEffect, createSignal, type Accessor } from "solid-js" +import { createEffect, createMemo, createSignal, on, onCleanup, type Accessor } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { useFilteredList } from "@opencode/ui/hooks" import { createComposerAttachments, type ComposerAttachmentConfig } from "../attachments/attachments" @@ -80,10 +80,35 @@ export function createComposerEditor(input: { }) { let editor: HTMLElement | undefined let fileInput: HTMLInputElement | undefined + let focusFrame: number | undefined + let focusCursor: number | undefined + + const cancelFocus = () => { + if (focusFrame === undefined) return + cancelAnimationFrame(focusFrame) + focusFrame = undefined + focusCursor = undefined + } + + const flushFocus = () => { + const cursor = focusCursor + + if (cursor === undefined) return + cancelFocus() + editor?.focus() + setEditorCursor(editor, cursor) + } + + onCleanup(cancelFocus) const draft = createComposerEditorActions(input.store) const [localQuoteComment, setLocalQuoteComment] = createSignal() const quoteComment = input.quoteComment ?? { key: localQuoteComment, set: setLocalQuoteComment } const [state, setState] = input.state ?? createComposerEditorState(draft.state.mode) + const [savedHistory, setSavedHistory] = createStore<{ entry?: ComposerHistoryEntry; cursor?: number }>({}) + const [cycle, setCycle] = createStore<{ index: number; entry?: ComposerHistoryEntry; cursor?: number }>({ index: -1 }) + const resetCycle = () => setCycle({ index: -1, entry: undefined, cursor: undefined }) + + createEffect(on([() => draft.state, () => state.mode], resetCycle, { defer: true })) function addPart(part: ComposerPersistedState["prompt"][number]) { if (isAttachment(part)) return false @@ -115,6 +140,8 @@ export function createComposerEditor(input: { : undefined const attach = () => { + resetCycle() + if (!attachments) { input.view.add?.onAttach() @@ -174,9 +201,42 @@ export function createComposerEditor(input: { }) const list = () => (state.popover.type === "context" ? contextList : commandList) - const suggestions = () => list().flat() + + const history = createMemo(() => + (input.history?.entries(state.mode) ?? []).map((entry, index) => ({ + id: `history:${index}`, + kind: "history", + label: entry.prompt.map((part) => ("content" in part ? part.content : "")).join(""), + description: entry.prompt.flatMap((part) => (isAttachment(part) ? [part.filename] : [])).join(", "), + history: entry, + })), + ) + + const suggestions = () => { + if (state.popover.type !== "history") return list().flat() + const query = state.popover.query.trim().toLowerCase() + + return history() + .filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(query)) + .toReversed() + } const execute = (command: ComposerInteractionCommand) => { + if (command.type === "history.preview") { + const item = history().find((entry) => entry.id === command.id) + + if (item?.history) + applyHistory(item.history, { focus: state.popover.type === "history" && !state.popover.searching }) + + return + } + + if (command.type === "history.restore") { + if (savedHistory.entry) applyHistory(savedHistory.entry, { cursor: savedHistory.cursor }) + + return + } + if (command.type === "draft.setText") { draft.setText(command.value) @@ -209,10 +269,32 @@ export function createComposerEditor(input: { return } - if (command.type === "focus.editor") requestAnimationFrame(() => editor?.focus()) + if (command.type === "focus.editor") { + if (state.popover.type === "history") return restoreFocus() + requestAnimationFrame(() => editor?.focus()) + } } function dispatch(event: ComposerInteractionEvent) { + if (event.type === "history.search" || event.type === "popover.close") cancelFocus() + + if ( + event.type === "history.open" || + event.type === "commands.open" || + event.type === "context.open" || + event.type === "popover.select" + ) + resetCycle() + + if (event.type === "popover.select" && event.item.kind === "history") { + if (!event.item.history) return false + setState({ popover: { type: "closed" }, focus: "editor" }) + applyHistory(event.item.history) + setSavedHistory({ entry: undefined, cursor: undefined }) + + return true + } + const mode = state.mode const result = transitionComposer(state, event, draft.state) const action = event.type === "popover.select" ? input.onSuggestionSelect?.(event.item) : undefined @@ -231,6 +313,8 @@ export function createComposerEditor(input: { if (event.type !== "popover.select") result.commands.forEach(execute) + if (state.popover.type !== "history") setSavedHistory({ entry: undefined, cursor: undefined }) + if (mode !== result.state.mode) { if (result.state.mode === "shell") input.view.shell?.onOpen() @@ -246,6 +330,23 @@ export function createComposerEditor(input: { } const onKeyDown = (event: KeyboardEvent) => { + flushFocus() + + if ( + state.popover.type === "history" && + (event.metaKey || event.ctrlKey) && + !event.altKey && + !event.shiftKey && + !event.isComposing && + event.key.toLowerCase() === "f" + ) { + event.preventDefault() + event.stopPropagation() + dispatch({ type: "history.search" }) + + return true + } + if ( state.mode === "normal" && (event.metaKey || event.ctrlKey) && @@ -302,8 +403,11 @@ export function createComposerEditor(input: { !event.altKey && !event.ctrlKey && !event.metaKey && + !event.isComposing && (event.key === "ArrowUp" || event.key === "ArrowDown") && - navigateHistory(event.key === "ArrowUp" ? "up" : "down") + (event.shiftKey + ? event.key === "ArrowUp" && openHistory() + : navigateHistory(event.key === "ArrowUp" ? "up" : "down")) ) { event.preventDefault() @@ -322,59 +426,74 @@ export function createComposerEditor(input: { }) const restoreFocus = (cursor = draft.state.cursor ?? promptLength(draft.state.prompt)) => { - requestAnimationFrame(() => { - editor?.focus() - setEditorCursor(editor, cursor) - }) + cancelFocus() + focusCursor = cursor + focusFrame = requestAnimationFrame(flushFocus) } - const applyHistory = (entry: ComposerHistoryEntry, position: "start" | "end") => { + const applyHistory = (entry: ComposerHistoryEntry, options?: { focus?: boolean; cursor?: number }) => { input.history?.restore?.(entry.metadata) - const cursor = position === "start" ? 0 : promptLength(entry.prompt) + const cursor = options?.cursor ?? promptLength(entry.prompt) draft.setPrompt(clonePrompt(entry.prompt), cursor) - restoreFocus(cursor) + + if (options?.focus !== false) restoreFocus(cursor) + } + + const openHistory = () => { + if (state.popover.type !== "closed" || !editor || !history().length) return false + const selection = window.getSelection() + + if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false + + if (draft.state.prompt.some((part) => "content" in part && part.content.length > 0)) return false + setSavedHistory({ + entry: { prompt: clonePrompt(draft.state.prompt), metadata: input.history?.capture?.() }, + cursor: draft.state.cursor, + }) + dispatch({ type: "history.open", id: history()[0]!.id }) + + return true } const navigateHistory = (direction: "up" | "down") => { - if (!input.history || !editor) return false + if (state.popover.type !== "closed" || !input.history || !editor) return false const selection = window.getSelection() if (!selection?.isCollapsed || !editor.contains(selection.anchorNode)) return false const text = draft.state.prompt.map((part) => ("content" in part ? part.content : "")).join("") - if (!canNavigateHistory(direction, text, editorCursor(editor), state.historyIndex >= 0)) return false + if (!canNavigateHistory(direction, text, editorCursor(editor), cycle.index >= 0)) return false const entries = input.history.entries(state.mode) if (direction === "up") { - if (entries.length === 0 || state.historyIndex >= entries.length - 1) return false + if (entries.length === 0 || cycle.index >= entries.length - 1) return false - if (state.historyIndex === -1) { - setState("savedHistory", { - prompt: clonePrompt(draft.state.prompt), - metadata: input.history.capture?.(), + if (cycle.index === -1) { + setCycle({ + entry: { prompt: clonePrompt(draft.state.prompt), metadata: input.history.capture?.() }, + cursor: draft.state.cursor, }) } - const index = state.historyIndex + 1 - setState("historyIndex", index) - applyHistory(entries[index]!, "start") + const index = cycle.index + 1 + setCycle("index", index) + applyHistory(entries[index]!, { cursor: 0 }) return true } - if (state.historyIndex < 0) return false + if (cycle.index < 0 || !cycle.entry) return false - if (state.historyIndex > 0) { - const index = state.historyIndex - 1 - setState("historyIndex", index) - applyHistory(entries[index]!, "end") + if (cycle.index > 0) { + const index = cycle.index - 1 + setCycle("index", index) + applyHistory(entries[index]!) return true } - const saved = state.savedHistory ?? { prompt: [{ type: "text", content: "", start: 0, end: 0 }] } - setState({ historyIndex: -1, savedHistory: undefined }) - applyHistory(saved, "end") + applyHistory(cycle.entry, { cursor: cycle.cursor }) + resetCycle() return true } @@ -424,6 +543,7 @@ export function createComposerEditor(input: { return (await input.searchContextFiles(query)).flatMap((item) => (item.path ? [item.path] : [])) }, removeContext(id: string) { + resetCycle() const item = draft.state.context.items.find((entry) => entry.key === id) if (item) input.onContextRemove?.(item) @@ -443,6 +563,7 @@ export function createComposerEditor(input: { return input.readImage?.(attachment.path) ?? Promise.resolve(undefined) }, removeAttachment(id: string) { + resetCycle() draft.removeAttachment(id) }, canSubmit() { @@ -469,6 +590,9 @@ export function createComposerEditor(input: { }, restoreFocus, onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) { + cancelFocus() + resetCycle() + if (prompt) draft.setPrompt(prompt, cursor) if (input.view.draftOnly) return @@ -492,6 +616,7 @@ export function createComposerEditor(input: { if (input.view.draftOnly) return if (attachments?.pending().length) return + resetCycle() input.view.submit.onSubmit(options) dispatch({ type: "popover.close" }) }, @@ -499,17 +624,20 @@ export function createComposerEditor(input: { input.view.submit.onStop() }, addHistory(prompt: ComposerPersistedState["prompt"], mode: "normal" | "shell") { + resetCycle() input.history?.add(prompt, mode) - setState({ historyIndex: -1, savedHistory: undefined }) }, resetHistory() { - setState({ historyIndex: -1, savedHistory: undefined }) + resetCycle() + + if (state.popover.type === "history") dispatch({ type: "popover.close" }) }, onPaste(event: ClipboardEvent) { const clipboard = event.clipboardData const text = clipboard?.getData("text/plain") if (attachments && shouldHandlePasteAsAttachment(clipboard, !!input.attachments?.readClipboardImage)) { + resetCycle() void attachments.handlePaste(event) return @@ -572,6 +700,7 @@ export function createComposerEditor(input: { }, addAttachments(files: File[]) { if (!attachments) return + resetCycle() void attachments.addAttachments(files) restoreFocus() }, diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index 2b211e9b338..4a99c094980 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -528,6 +528,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? Object.defineProperty(controller, "model", { get: () => adapter.controls().model }) command.register("composer-editor", () => [ + { + id: "prompt.history.search", + title: language.t("ui.promptInput.history.search"), + keybind: "mod+f", + hidden: true, + editable: true, + when: () => controller.state.popover.type === "history", + onSelect: () => void controller.dispatch({ type: "history.search" }), + }, { id: "file.attach", title: language.t("prompt.action.attachFile"), diff --git a/packages/app/src/composer/suggestions/machine.ts b/packages/app/src/composer/suggestions/machine.ts index c2d4758c3c6..d65393509b6 100644 --- a/packages/app/src/composer/suggestions/machine.ts +++ b/packages/app/src/composer/suggestions/machine.ts @@ -1,4 +1,4 @@ -import type { ComposerHistoryEntry, ComposerPersistedState, ComposerSuggestion } from "../types" +import type { ComposerPersistedState, ComposerSuggestion } from "../types" import { isAttachment } from "../prompt-parts" export type ComposerInteractionState = { @@ -8,17 +8,19 @@ export type ComposerInteractionState = { | { type: "context"; query: string; activeID?: string } | { type: "command-inline"; query: string; activeID?: string } | { type: "command-menu"; query: string; activeID?: string } + | { type: "history"; query: string; searching: boolean; activeID?: string } drag: "idle" | "active" - focus: "editor" | "command-search" | "external" + focus: "editor" | "command-search" | "history-search" | "external" activeContextID?: string - historyIndex: number - savedHistory?: ComposerHistoryEntry } export type ComposerInteractionEvent = | { type: "input.changed"; value: string; persist?: boolean } | { type: "commands.open" } | { type: "context.open" } + | { type: "history.open"; id: string } + | { type: "history.search" } + | { type: "history.browse" } | { type: "popover.query"; value: string } | { type: "popover.results"; ids: string[] } | { type: "popover.active"; id: string } @@ -41,6 +43,8 @@ export type ComposerInteractionCommand = | { type: "suggestion.select"; id: string } | { type: "focus.editor" } | { type: "focus.command-search" } + | { type: "history.preview"; id: string } + | { type: "history.restore" } export type ComposerEditorTransition = { state: ComposerInteractionState @@ -54,7 +58,6 @@ export function createComposerInteractionState(): ComposerInteractionState { popover: { type: "closed" }, drag: "idle", focus: "external", - historyIndex: -1, } } @@ -69,6 +72,17 @@ export function transitionComposer( if (event.type === "context.open") return openContext(state) + if (event.type === "history.open") + return changed( + { ...state, popover: { type: "history", query: "", searching: false, activeID: event.id }, focus: "editor" }, + [{ type: "history.preview", id: event.id }], + ) + + if (event.type === "history.search" && state.popover.type === "history") + return changed({ ...state, popover: { ...state.popover, searching: true }, focus: "history-search" }) + + if (event.type === "history.browse") return browseHistory(state) + if (event.type === "popover.query") return queryChanged(state, event.value) if (event.type === "popover.results") return resultsChanged(state, event.ids) @@ -165,6 +179,10 @@ function openContext(state: ComposerInteractionState): ComposerEditorTransition function queryChanged(state: ComposerInteractionState, query: string): ComposerEditorTransition { if (state.popover.type === "closed") return unchanged(state) + + if (state.popover.type === "history") + return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }) + const popover = state.popover.type === "context" ? "context" : "command" return changed({ ...state, popover: { ...state.popover, query, activeID: undefined } }, [ @@ -172,19 +190,41 @@ function queryChanged(state: ComposerInteractionState, query: string): ComposerE ]) } +function browseHistory(state: ComposerInteractionState): ComposerEditorTransition { + if (state.popover.type !== "history") return unchanged(state) + + return changed( + { ...state, popover: { ...state.popover, searching: false, query: "" }, focus: "editor" }, + [{ type: "focus.editor" }], + true, + ) +} + function resultsChanged(state: ComposerInteractionState, ids: string[]): ComposerEditorTransition { if (state.popover.type === "closed") return unchanged(state) - const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0] + + const activeID = + state.popover.activeID && ids.includes(state.popover.activeID) + ? state.popover.activeID + : state.popover.type === "history" + ? ids.at(-1) + : ids[0] if (activeID === state.popover.activeID) return unchanged(state) - return changed({ ...state, popover: { ...state.popover, activeID } }) + return changed( + { ...state, popover: { ...state.popover, activeID } }, + state.popover.type === "history" && activeID ? [{ type: "history.preview", id: activeID }] : [], + ) } function activeChanged(state: ComposerInteractionState, id: string): ComposerEditorTransition { if (state.popover.type === "closed" || state.popover.activeID === id) return unchanged(state) - return changed({ ...state, popover: { ...state.popover, activeID: id } }) + return changed( + { ...state, popover: { ...state.popover, activeID: id } }, + state.popover.type === "history" ? [{ type: "history.preview", id }] : [], + ) } function suggestionSelected( @@ -224,6 +264,12 @@ function keyDown( state: ComposerInteractionState, event: Extract, ): ComposerEditorTransition { + if (state.popover.type === "history" && (event.key === "Escape" || (event.ctrl && event.key.toLowerCase() === "g"))) { + if (state.popover.searching && event.key === "Escape") return browseHistory(state) + + return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "history.restore" }], true) + } + if (event.ctrl && event.key.toLowerCase() === "g") { if (state.popover.type === "closed") return unchanged(state) @@ -238,7 +284,7 @@ function keyDown( return unchanged(state) } - if (event.key === "Escape") { + if (event.key === "Escape" || (state.popover.type === "history" && event.key === "Tab")) { return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "focus.editor" }], true) } @@ -265,7 +311,16 @@ function keyDown( : event.ids.length - 1 : (current + direction + event.ids.length) % event.ids.length - return changed({ ...state, popover: { ...state.popover, activeID: event.ids[index] } }, [], true) + const next = + state.popover.type === "history" && current >= 0 + ? Math.max(0, Math.min(event.ids.length - 1, current + direction)) + : index + + return changed( + { ...state, popover: { ...state.popover, activeID: event.ids[next] } }, + state.popover.type === "history" ? [{ type: "history.preview", id: event.ids[next] }] : [], + true, + ) } function promptText(persisted: ComposerPersistedState) { diff --git a/packages/app/src/composer/types.ts b/packages/app/src/composer/types.ts index 19ddf4a52af..597bdc2fea0 100644 --- a/packages/app/src/composer/types.ts +++ b/packages/app/src/composer/types.ts @@ -47,7 +47,7 @@ export type ComposerOption = { export type ComposerSuggestion = { id: string - kind: "agent" | "command" | "file" | "reference" | "resource" | "skill" | "session" + kind: "agent" | "command" | "file" | "reference" | "resource" | "skill" | "session" | "history" label: string title?: string trigger?: string @@ -55,5 +55,6 @@ export type ComposerSuggestion = { path?: string keybind?: string[] recent?: boolean + history?: ComposerHistoryEntry mention?: ComposerFilePart | ComposerAgentPart | ComposerSkillPart | ComposerSessionPart } diff --git a/packages/app/src/shell/commands/dialog.css b/packages/app/src/shell/commands/dialog.css index 9670fb76541..c5b40f18cba 100644 --- a/packages/app/src/shell/commands/dialog.css +++ b/packages/app/src/shell/commands/dialog.css @@ -28,44 +28,6 @@ padding: 0; } -.command-palette-search { - flex-shrink: 0; - padding: 6px; -} - -.command-palette-search [data-component="text-input-v2"] { - width: 100%; - height: 36px; - border-radius: 6px; - outline: 0; - background: color-mix(in srgb, var(--v2-background-bg-layer-02) 60%, transparent); - box-shadow: none; - transition: - background-color 120ms ease-in-out, - box-shadow 120ms ease-in-out; -} - -.command-palette-search [data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]), -.command-palette-search [data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) { - background: var(--v2-background-bg-layer-02); - box-shadow: none; -} - -.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-value"] { - border-radius: 6px; - background: transparent; - gap: 8px; -} - -.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-leading-icon"] { - padding-left: 12px; -} - -.command-palette-search [data-component="text-input-v2"] [data-slot="text-input-v2-input"] { - border-radius: 6px; - background: transparent; -} - .command-palette-scroll { min-height: 0; flex: 1; @@ -95,37 +57,6 @@ user-select: none; } -.command-palette-row { - display: flex; - width: 100%; - height: 36px; - flex-shrink: 0; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0 12px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--v2-text-text-base); - text-align: left; - cursor: default; - scroll-margin: 6px 0; -} - -.command-palette-row[data-active] { - background: var(--v2-overlay-simple-overlay-hover); -} - -.command-palette-row:focus-visible { - background: var(--v2-overlay-simple-overlay-hover); - outline: none; -} - -[data-color-scheme="dark"] .command-palette-row:is([data-active], :focus-visible) { - background: var(--v2-alpha-light-10); -} - .command-palette-row-main { display: flex; min-width: 0; @@ -146,18 +77,6 @@ gap: 6px; } -.command-palette-title { - min-width: 0; - overflow: hidden; - color: var(--v2-text-text-base); - font-size: 13px; - font-weight: 530; - line-height: 16px; - letter-spacing: -0.04px; - text-overflow: ellipsis; - white-space: nowrap; -} - .command-palette-description, .command-palette-meta { min-width: 0; diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index d297ad790ca..f06be1dd6f9 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -16,6 +16,7 @@ import { getRelativeTime } from "@/shell/time" import { createCommandPaletteFileEntry, createCommandPaletteModel, type CommandPaletteEntry } from "./palette" import { createCommandPaletteSearch, matchesCommandPaletteEntry } from "./search" import "./dialog.css" +import "../palette-menu.css" export { matchesCommandPaletteEntry } @@ -115,7 +116,7 @@ export function CommandPaletteView(props: { return ( -