feat(app): focus prompt history search (#59626)

This commit is contained in:
usrnk1
2026-10-27 16:04:46 +01:00
committed by GitHub
Unverified
parent 4ba064f653
commit d012e07e63
6 changed files with 32 additions and 115 deletions
+19 -23
View File
@@ -96,20 +96,22 @@ story(
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(search).toBeFocused()
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",
"Review the composer layout Keep keyboard navigation accessible",
"Run the focused tests",
])
await expect(component.getByRole("option", { selected: true })).toHaveText("Explain the latest changes")
await input.press("ArrowUp")
await expect(
component.getByRole("option", { name: "Explain the latest changes", exact: true }).locator("bdi"),
).toHaveCSS("font-weight", "400")
await search.press("ArrowDown")
await expect(component.getByRole("option", { selected: true })).toHaveText(
"Review the composer layout Keep keyboard navigation accessible",
)
@@ -140,20 +142,16 @@ story(
.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 search.press("ArrowDown")
await expect(input).toHaveText("Run the focused tests")
await expect(composer).toHaveCSS("height", `${height}px`)
await input.press("ArrowUp")
await search.press("ArrowDown")
await expect(input).toHaveText("Run the focused tests")
await input.press("ArrowDown")
await search.press("ArrowUp")
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
@@ -161,7 +159,7 @@ story(
.evaluate((element) => element.scrollHeight > element.clientHeight),
)
.toBe(true)
await input.press("Enter")
await search.press("Enter")
await expect(options).toHaveCount(0)
await expect(content).toHaveCSS("mask-image", "none")
await expect(viewport).not.toHaveCSS("mask-image", "none")
@@ -186,7 +184,7 @@ story(
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 search.press("Escape")
await expect(options).toHaveCount(0)
await expect(input).toBeFocused()
await expect(input).toBeEmpty()
@@ -196,13 +194,12 @@ story(
},
)
story("searches history on demand, returns to browsing, and keeps shell history separate", async ({ mount, page }) => {
story("searches history, cancels with Escape, 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)
@@ -211,13 +208,10 @@ story("searches history on demand, returns to browsing, and keeps shell history
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)
@@ -246,6 +240,8 @@ story("searches history on demand, returns to browsing, and keeps shell history
await input.press("Shift+ArrowUp")
await expect(input).toHaveText("Explain the latest changes")
await expect(component.getByRole("option")).toHaveCount(3)
await expect(search).toBeFocused()
await search.press("Enter")
await expect(input).toBeFocused()
await input.press("Shift+Digit1")
await expect(component.getByRole("option")).toHaveCount(0)
@@ -256,11 +252,11 @@ story("searches history on demand, returns to browsing, and keeps shell history
await input.press("Shift+ArrowUp")
await expect(input).toHaveText("bun typecheck")
await expect(component.getByRole("option")).toHaveText(["bun typecheck"])
await input.press("Escape")
await search.press("Escape")
await expect(input).toBeFocused()
await expect(input).toBeEmpty()
await input.press("Shift+ArrowUp")
await input.press("Enter")
await search.press("Enter")
await expect(input).toHaveText("bun typecheck")
await expect(component.getByRole("status")).toHaveText("Ready")
})
@@ -270,7 +266,7 @@ story("pins history search above the scrolling prompts", async ({ mount }) => {
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 search = menu.getByRole("combobox", { name: "Prompt history", exact: true })
const viewport = menu.locator("[data-scrollable]")
await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
@@ -287,7 +283,7 @@ story("pins history search above the scrolling prompts", async ({ mount }) => {
await expect.poll(clippedTop).toBe(false)
for (let index = 0; index < 12; index++) await input.press("ArrowUp")
for (let index = 0; index < 12; index++) await search.press("ArrowDown")
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)
+5 -31
View File
@@ -45,7 +45,6 @@ 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"
@@ -185,8 +184,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
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"),
@@ -219,9 +216,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={search()}
history={state.popover.type === "history" ? state.mode : undefined}
historyBrowsing={state.popover.type === "history" && !state.popover.searching}
editor={() => 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 })}
@@ -297,11 +291,6 @@ export function ComposerEditor(props: ComposerEditorProps) {
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"}
@@ -1163,9 +1152,6 @@ function ComposerEditorPopover(props: {
items: ComposerSuggestion[]
activeID?: string
history?: ComposerMode
historyBrowsing?: boolean
editor?: () => HTMLElement | undefined
onSearch?: () => void
onClose?: () => void
search?: {
value: string
@@ -1205,7 +1191,6 @@ function ComposerEditorPopover(props: {
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?.()
}
@@ -1239,7 +1224,7 @@ function ComposerEditorPopover(props: {
<Show when={props.history}>
<Show when={props.search}>
{(search) => (
<div class="shrink-0 px-2 py-1">
<div class="flex h-7 shrink-0 items-center px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.isConnected && element.focus())}
value={search().value}
@@ -1260,16 +1245,6 @@ function ComposerEditorPopover(props: {
</div>
)}
</Show>
<Show when={props.historyBrowsing}>
<button
type="button"
class="flex h-7 shrink-0 items-center justify-between gap-2 rounded-md px-2 text-[13px] leading-4 text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover"
onClick={props.onSearch}
>
<span>{i18n.t("ui.promptInput.history.searchAction")}</span>
<Keybind keys={formatKeybindParts("mod+f")} variant="ghost" />
</button>
</Show>
<div class="min-h-0 flex-1 overflow-y-auto" data-scrollable>
<div id={listID} role="listbox" aria-label={i18n.t("ui.promptInput.history")} class="flex flex-col">
<Show
@@ -1296,11 +1271,10 @@ function ComposerEditorPopover(props: {
<bdi
dir={props.history === "shell" ? "ltr" : "auto"}
class="palette-menu-title flex-1 text-start"
style={
props.history === "shell"
? { "font-family": "var(--font-family-mono)", "font-weight": 440 }
: undefined
}
style={{
"font-family": props.history === "shell" ? "var(--font-family-mono)" : undefined,
"font-weight": props.history === "shell" ? 440 : "var(--font-weight-regular)",
}}
>
{item.label || item.description || i18n.t("ui.promptInput.history.contextOnly")}
</bdi>
@@ -264,17 +264,14 @@ export function createComposerEditor(input: {
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()
return history().filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(query))
}
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 })
if (item?.history) applyHistory(item.history, { focus: false })
return
}
@@ -324,7 +321,7 @@ export function createComposerEditor(input: {
}
function dispatch(event: ComposerInteractionEvent) {
if (event.type === "history.search" || event.type === "popover.close") cancelFocus()
if (event.type === "popover.close") cancelFocus()
if (
event.type === "history.open" ||
@@ -380,21 +377,6 @@ 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) &&
-9
View File
@@ -562,15 +562,6 @@ 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"),
@@ -8,7 +8,7 @@ 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 }
| { type: "history"; query: string; activeID?: string }
drag: "idle" | "active"
focus: "editor" | "command-search" | "history-search" | "external"
activeContextID?: string
@@ -19,8 +19,6 @@ export type ComposerInteractionEvent =
| { 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 }
@@ -73,15 +71,9 @@ 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)
return changed({ ...state, popover: { type: "history", query: "", activeID: event.id }, focus: "history-search" }, [
{ type: "history.preview", id: event.id },
])
if (event.type === "popover.query") return queryChanged(state, event.value)
@@ -193,25 +185,10 @@ 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
: state.popover.type === "history"
? ids.at(-1)
: ids[0]
const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0]
if (activeID === state.popover.activeID) return unchanged(state)
@@ -269,8 +246,6 @@ function keyDown(
event: Extract<ComposerInteractionEvent, { type: "key.down" }>,
): 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)
}
-1
View File
@@ -164,7 +164,6 @@ const source = {
"ui.promptInput.commands": "Commands",
"ui.promptInput.history": "Prompt history",
"ui.promptInput.history.search": "Search prompt history…",
"ui.promptInput.history.searchAction": "Search",
"ui.promptInput.history.contextOnly": "Prompt with context",
"ui.promptInput.dropFiles": "Drop files to add",
"ui.promptInput.dropFiles.image": "Drop images or files to add",