mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 11:39:41 +03:00
feat(app): add searchable prompt history picker (#57716)
This commit is contained in:
committed by
GitHub
Unverified
parent
46dd7715e6
commit
3449855dab
@@ -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 }) => {
|
||||
|
||||
@@ -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<ComposerPersistedState>({
|
||||
@@ -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 (
|
||||
<div class="mx-auto flex min-h-80 w-full max-w-200 flex-col justify-end gap-3 rounded-xl bg-v2-background-bg-deep p-6">
|
||||
<div
|
||||
class="mx-auto flex min-h-80 w-full max-w-200 flex-col justify-end gap-3 rounded-xl bg-v2-background-bg-deep p-6"
|
||||
style={{ "min-height": props.manyHistory ? "512px" : undefined }}
|
||||
>
|
||||
<output class="text-12-regular text-text-weak" aria-live="polite">
|
||||
{story.activity}
|
||||
</output>
|
||||
@@ -252,6 +302,15 @@ export default {
|
||||
|
||||
export const EmptyDraft = { render: () => <ComposerStory /> }
|
||||
|
||||
export const PromptHistory = {
|
||||
args: { many: false },
|
||||
render: (args: { many: boolean }) => (
|
||||
<div class="w-[min(640px,calc(100vw-32px))]">
|
||||
<ComposerStory history manyHistory={args.many} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const TextDraft = { render: () => <ComposerStory prompt={text("Explain this change")} /> }
|
||||
|
||||
export const MultilineDraft = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<div class={`relative size-full flex flex-col gap-0 ${props.class ?? ""}`}>
|
||||
@@ -189,21 +221,30 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
/>
|
||||
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
|
||||
<ComposerEditorPopover
|
||||
id={suggestionsID}
|
||||
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
|
||||
items={props.controller.suggestions()}
|
||||
boundary={props.suggestionBoundary}
|
||||
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 })}
|
||||
/>
|
||||
</Show>
|
||||
<form
|
||||
ref={composer}
|
||||
data-component="composer"
|
||||
data-dock-border-underlay={props.borderUnderlay ? "true" : undefined}
|
||||
class="group/composer relative w-full overflow-clip rounded-xl bg-v2-background-bg-base"
|
||||
style={{ height: historyLayout.height === undefined ? undefined : `${historyLayout.height}px` }}
|
||||
classList={{
|
||||
"shadow-[var(--v2-elevation-raised)]": !props.borderUnderlay,
|
||||
"flex flex-col": historyLayout.height !== undefined,
|
||||
}}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
@@ -215,123 +256,153 @@ export function ComposerEditor(props: ComposerEditorProps) {
|
||||
onDragLeave={props.controller.onDragLeave}
|
||||
onDrop={props.controller.onDrop}
|
||||
>
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
uploads={props.controller.uploads()}
|
||||
comments={props.controller.comments()}
|
||||
files={props.controller.files()}
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
preview={props.controller.previewAttachment}
|
||||
onAttachmentRemove={(attachment) => 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}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<ScrollView
|
||||
data-component="composer-scroll"
|
||||
class="max-h-[180px]"
|
||||
<div
|
||||
ref={content}
|
||||
data-slot="composer-content"
|
||||
data-history-preview={historyLayout.height !== undefined ? "" : undefined}
|
||||
class="min-h-0"
|
||||
classList={{ "flex-1 overflow-y-auto no-scrollbar": historyLayout.height !== undefined }}
|
||||
style={{ "--composer-fade-bottom": `${fadeBottom()}px` }}
|
||||
onScroll={updateScroll}
|
||||
viewportRef={(element) => {
|
||||
viewport = element
|
||||
element.tabIndex = -1
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(element) => {
|
||||
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" })}
|
||||
/>
|
||||
<Show when={!props.controller.value()}>
|
||||
<div
|
||||
dir={state.mode === "normal" ? "auto" : "ltr"}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 px-3 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell" }}
|
||||
style={{ "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, "text-align": "start" }}
|
||||
>
|
||||
{view.placeholder?.() ??
|
||||
(state.mode === "shell"
|
||||
? i18n.t("ui.promptInput.placeholder.shell")
|
||||
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
|
||||
</div>
|
||||
<Show when={state.mode === "normal"}>
|
||||
<ComposerAttachments
|
||||
attachments={props.controller.attachments()}
|
||||
uploads={props.controller.uploads()}
|
||||
comments={props.controller.comments()}
|
||||
files={props.controller.files()}
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
preview={props.controller.previewAttachment}
|
||||
onAttachmentRemove={(attachment) => 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}
|
||||
/>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
|
||||
<div class="flex h-11 items-center px-2">
|
||||
<ScrollView
|
||||
data-component="composer-scroll"
|
||||
class={historyLayout.height === undefined ? "max-h-[180px]" : undefined}
|
||||
onScroll={updateScroll}
|
||||
viewportRef={(element) => {
|
||||
viewport = element
|
||||
element.tabIndex = -1
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={(element) => {
|
||||
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" })}
|
||||
/>
|
||||
<Show when={!props.controller.value()}>
|
||||
<div
|
||||
dir={state.mode === "normal" ? "auto" : "ltr"}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 px-3 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
|
||||
classList={{ "font-mono! tracking-[-0.04px]": state.mode === "shell" }}
|
||||
style={{ "unicode-bidi": state.mode === "normal" ? "plaintext" : undefined, "text-align": "start" }}
|
||||
>
|
||||
{view.placeholder?.() ??
|
||||
(state.mode === "shell"
|
||||
? i18n.t("ui.promptInput.placeholder.shell")
|
||||
: i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }))}
|
||||
</div>
|
||||
</Show>
|
||||
</ScrollView>
|
||||
</div>
|
||||
|
||||
<div class="flex h-11 shrink-0 items-center px-2">
|
||||
<div
|
||||
class="flex shrink-0 items-center"
|
||||
aria-hidden={state.mode === "shell"}
|
||||
@@ -1092,9 +1163,15 @@ export function ComposerEditorSelect(props: {
|
||||
}
|
||||
|
||||
export function ComposerEditorPopover(props: {
|
||||
id?: string
|
||||
emptyLabel: string
|
||||
items: ComposerSuggestion[]
|
||||
activeID?: string
|
||||
history?: ComposerMode
|
||||
historyBrowsing?: boolean
|
||||
editor?: () => 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 (
|
||||
<div
|
||||
ref={container}
|
||||
data-component="composer-suggestions"
|
||||
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
|
||||
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
|
||||
classList={{ "overflow-auto": !props.history, "overflow-hidden": !!props.history }}
|
||||
style={{ "max-height": `${store.maxHeight}px` }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<Show when={props.search}>
|
||||
{(search) => (
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
<input
|
||||
ref={(element) => 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()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={props.items.length > 0}
|
||||
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
data-suggestion-id={item.id}
|
||||
data-active={props.activeID === item.id ? "" : undefined}
|
||||
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
|
||||
onPointerMove={() => props.onActiveChange(item)}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ComposerSuggestionIcon item={item} />
|
||||
<bdi dir="auto" class="shrink-0 text-v2-text-text-base">
|
||||
{item.label}
|
||||
</bdi>
|
||||
<Show when={item.description}>
|
||||
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind?.length}>
|
||||
<span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={props.history}>
|
||||
<Show when={props.search}>
|
||||
{(search) => (
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
<input
|
||||
ref={(element) => 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()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</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>
|
||||
<ScrollView class="min-h-0 flex-1">
|
||||
<div id={listID} role="listbox" aria-label={i18n.t("ui.promptInput.history")} class="flex flex-col">
|
||||
<Show
|
||||
when={props.items.length}
|
||||
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
id={`${listID}-${item.id}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={props.activeID === item.id}
|
||||
tabindex={-1}
|
||||
data-suggestion-id={item.id}
|
||||
data-active={props.activeID === item.id ? "" : undefined}
|
||||
class="palette-menu-row"
|
||||
data-size="compact"
|
||||
onPointerMove={(event) => {
|
||||
if (event.movementX || event.movementY) props.onActiveChange(item)
|
||||
}}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
<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
|
||||
}
|
||||
>
|
||||
{item.label || item.description || i18n.t("ui.promptInput.history.contextOnly")}
|
||||
</bdi>
|
||||
<Show when={item.description}>
|
||||
<Icon name="file-document" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</ScrollView>
|
||||
</Show>
|
||||
<Show when={!props.history}>
|
||||
<Show when={props.search}>
|
||||
{(search) => (
|
||||
<div class="shrink-0 px-2 py-1">
|
||||
<input
|
||||
ref={(element) => 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()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
when={props.items.length > 0}
|
||||
fallback={<div class="px-2 py-1 text-v2-text-text-muted">{props.emptyLabel}</div>}
|
||||
>
|
||||
<For each={props.items}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
data-suggestion-id={item.id}
|
||||
data-active={props.activeID === item.id ? "" : undefined}
|
||||
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
|
||||
onPointerMove={() => props.onActiveChange(item)}
|
||||
onClick={() => props.onSelect(item)}
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<ComposerSuggestionIcon item={item} />
|
||||
<bdi dir="auto" class="shrink-0 text-v2-text-text-base">
|
||||
{item.label}
|
||||
</bdi>
|
||||
<Show when={item.description}>
|
||||
<span class="min-w-0 truncate text-v2-text-text-muted">{item.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={item.keybind?.length}>
|
||||
<span class="shrink-0 text-v2-text-text-muted">{item.keybind?.join("+")}</span>
|
||||
</Show>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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<string>()
|
||||
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<ComposerSuggestion[]>(() =>
|
||||
(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()
|
||||
},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<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)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<Dialog class="command-palette" size="large">
|
||||
<DialogBody class="command-palette-body">
|
||||
<div class="command-palette-search">
|
||||
<div class="palette-menu-search">
|
||||
<TextInput
|
||||
value={store.query}
|
||||
autofocus
|
||||
@@ -215,7 +216,7 @@ function PaletteRow(props: {
|
||||
<button
|
||||
id={props.id}
|
||||
type="button"
|
||||
class="command-palette-row group"
|
||||
class="command-palette-row palette-menu-row group"
|
||||
role="option"
|
||||
aria-selected={props.active}
|
||||
data-active={props.active ? "" : undefined}
|
||||
@@ -241,7 +242,7 @@ function PaletteRow(props: {
|
||||
<Match when={props.item.type === "command" || props.item.type === "setting"}>
|
||||
<div class="command-palette-row-main">
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title">{props.item.title}</span>
|
||||
<span class="command-palette-title palette-menu-title">{props.item.title}</span>
|
||||
<Show when={props.item.description}>
|
||||
<span class="command-palette-description">{props.item.description}</span>
|
||||
</Show>
|
||||
@@ -273,7 +274,10 @@ function PaletteRow(props: {
|
||||
</Show>
|
||||
</div>
|
||||
<div class="command-palette-row-text">
|
||||
<span class="command-palette-title" classList={{ "opacity-70": !!props.item.archived }}>
|
||||
<span
|
||||
class="command-palette-title palette-menu-title"
|
||||
classList={{ "opacity-70": !!props.item.archived }}
|
||||
>
|
||||
{props.item.title}
|
||||
</span>
|
||||
<Show when={props.item.description}>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
.palette-menu-search {
|
||||
flex-shrink: 0;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.palette-menu-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;
|
||||
}
|
||||
|
||||
.palette-menu-search
|
||||
[data-component="text-input-v2"]:where(:hover, :focus-within):not([data-disabled], [data-invalid]) {
|
||||
background: var(--v2-background-bg-layer-02);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.palette-menu-search [data-slot="text-input-v2-value"] {
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.palette-menu-search [data-slot="text-input-v2-leading-icon"] {
|
||||
padding-inline-start: 12px;
|
||||
}
|
||||
|
||||
.palette-menu-search [data-slot="text-input-v2-input"] {
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.palette-menu-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: start;
|
||||
cursor: default;
|
||||
scroll-margin: 6px 0;
|
||||
}
|
||||
|
||||
.palette-menu-row:is([data-active], :focus-visible) {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.palette-menu-row[data-size="compact"] {
|
||||
height: 28px;
|
||||
padding-inline: 8px;
|
||||
scroll-margin-block: 0;
|
||||
}
|
||||
|
||||
[data-color-scheme="dark"] .palette-menu-row:is([data-active], :focus-visible) {
|
||||
background: var(--v2-alpha-light-10);
|
||||
}
|
||||
|
||||
.palette-menu-title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--v2-text-text-base);
|
||||
font-size: 13px;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.palette-menu-search [data-component="text-input-v2"] {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,10 @@ const source = {
|
||||
|
||||
"ui.promptInput.noMatchingItems": "No matching items",
|
||||
"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",
|
||||
"ui.promptInput.dropFiles.pdf": "Drop PDFs or files to add",
|
||||
|
||||
Reference in New Issue
Block a user