diff --git a/packages/app/e2e/models/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts index 01e72464c..220a0baa1 100644 --- a/packages/app/e2e/models/model-picker.spec.ts +++ b/packages/app/e2e/models/model-picker.spec.ts @@ -28,7 +28,6 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession } const key = await target.getAttribute("data-key") if (!key) throw new Error("Failed to resolve model key from list item") - const name = (await target.locator("span").first().innerText()).trim() const model = key.split(":").slice(1).join(":") await input.fill(model) @@ -37,6 +36,13 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession } await expect(dialog).toHaveCount(0) - const form = page.locator(promptSelector).locator("xpath=ancestor::form[1]") - await expect(form.locator('[data-component="button"]').filter({ hasText: name }).first()).toBeVisible() + await page.locator(promptSelector).click() + await page.keyboard.type("/model") + await expect(command).toBeVisible() + await command.hover() + await page.keyboard.press("Enter") + + const dialogAgain = page.getByRole("dialog") + await expect(dialogAgain).toBeVisible() + await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible() }) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index a196db231..9f7afb8cd 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -121,7 +121,7 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={8} + gutter={4} > {props.children} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e21798738..7813e01cd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -32,7 +32,6 @@ import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { Persist, persisted } from "@/utils/persist" -import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" @@ -94,7 +93,6 @@ export const PromptInput: Component = (props) => { const local = useLocal() const files = useFile() const prompt = usePrompt() - const commentCount = createMemo(() => prompt.context.items().filter((item) => !!item.comment?.trim()).length) const layout = useLayout() const comments = useComments() const params = useParams() @@ -105,7 +103,7 @@ export const PromptInput: Component = (props) => { const language = useLanguage() const platform = usePlatform() let editorRef!: HTMLDivElement - let fileInputRef!: HTMLInputElement + let fileInputRef: HTMLInputElement | undefined let scrollRef!: HTMLDivElement let slashPopoverRef!: HTMLDivElement @@ -223,14 +221,25 @@ export const PromptInput: Component = (props) => { mode: "normal", applyingHistory: false, }) - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: language.t(EXAMPLES[store.placeholder]), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) + + const commentCount = createMemo(() => { + if (store.mode === "shell") return 0 + return prompt.context.items().filter((item) => !!item.comment?.trim()).length + }) + + const contextItems = createMemo(() => { + const items = prompt.context.items() + if (store.mode !== "shell") return items + return items.filter((item) => !item.comment?.trim()) + }) + + const hasUserPrompt = createMemo(() => { + const sessionID = params.id + if (!sessionID) return false + const messages = sync.data.message[sessionID] + if (!messages) return false + return messages.some((m) => m.role === "user") + }) const MAX_HISTORY = 100 const [history, setHistory] = persisted( @@ -250,6 +259,18 @@ export const PromptInput: Component = (props) => { }), ) + const suggest = createMemo(() => !hasUserPrompt()) + + const placeholder = createMemo(() => + promptPlaceholder({ + mode: store.mode, + commentCount: commentCount(), + example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + suggest: suggest(), + t: (key, params) => language.t(key as Parameters[0], params as never), + }), + ) + const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const length = position === "start" ? 0 : promptLength(p) setStore("applyingHistory", true) @@ -282,6 +303,25 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) const escBlur = () => platform.platform === "desktop" && platform.os === "macos" + const pick = () => fileInputRef?.click() + + const setMode = (mode: "normal" | "shell") => { + setStore("mode", mode) + setStore("popover", null) + requestAnimationFrame(() => editorRef?.focus()) + } + + command.register("prompt-input", () => [ + { + id: "file.attach", + title: language.t("prompt.action.attachFile"), + category: language.t("command.category.file"), + keybind: "mod+u", + disabled: store.mode !== "normal", + onSelect: pick, + }, + ]) + const closePopover = () => setStore("popover", null) const resetHistoryNavigation = (force = false) => { @@ -326,6 +366,7 @@ export const PromptInput: Component = (props) => { createEffect(() => { params.id if (params.id) return + if (!suggest()) return const interval = setInterval(() => { setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) }, 6500) @@ -816,6 +857,13 @@ export const PromptInput: Component = (props) => { }) const handleKeyDown = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { + event.preventDefault() + if (store.mode !== "normal") return + pick() + return + } + if (event.key === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) { @@ -956,8 +1004,10 @@ export const PromptInput: Component = (props) => { } } + const variants = createMemo(() => ["default", ...local.model.variant.list()]) + return ( -
+
(slashPopoverRef = el)} @@ -977,8 +1027,8 @@ export const PromptInput: Component = (props) => { onSubmit={handleSubmit} classList={{ "group/prompt-input": true, - "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, - "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true, + "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true, + "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, }} @@ -988,7 +1038,7 @@ export const PromptInput: Component = (props) => { label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")} /> { const active = comments.active() return !!item.commentID && item.commentID === active?.id && item.path === active?.file @@ -1008,7 +1058,22 @@ export const PromptInput: Component = (props) => { onRemove={removeImageAttachment} removeLabel={language.t("prompt.attachment.remove")} /> -
(scrollRef = el)}> +
(scrollRef = el)} + onMouseDown={(e) => { + const target = e.target + if (!(target instanceof HTMLElement)) return + if ( + target.closest( + '[data-action="prompt-attach"], [data-action="prompt-submit"], [data-action="prompt-permissions"]', + ) + ) { + return + } + editorRef?.focus() + }} + >
{ @@ -1029,141 +1094,22 @@ export const PromptInput: Component = (props) => { onKeyDown={handleKeyDown} classList={{ "select-text": true, - "w-full p-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, + "w-full pl-3 pr-2 pt-2 pb-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "[&_[data-type=file]]:text-syntax-property": true, "[&_[data-type=agent]]:text-syntax-type": true, "font-mono!": store.mode === "shell", }} /> -
+
{placeholder()}
-
-
-
- - -
- - {language.t("prompt.mode.shell")} - {language.t("prompt.mode.shell.exit")} -
-
- - - = (props) => { e.currentTarget.value = "" }} /> -
- - - - - - -
- - -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
-
- -
- {language.t("prompt.action.send")} - -
-
-
- } + +
- - + + + + + + +
+ {language.t("prompt.action.stop")} + {language.t("common.key.esc")} +
+
+ +
+ {language.t("prompt.action.send")} + +
+
+ + } + > + +
+
+ + +
+
+ + + +
+
+
+ +
+
+
+ +
+ {language.t("prompt.mode.shell")} +
+
+ + + + + (x === "default" ? language.t("common.default") : x)} + onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} + class="capitalize max-w-[160px]" + valueClass="truncate text-13-regular" + triggerStyle={{ height: "28px" }} + variant="ghost" + /> + + +
+ +
+
+
+ + +
+
+
+
+
) } diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b575c3961..b138fe3ef 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -41,10 +41,9 @@ export const PromptContextItems: Component = (props) => { >
props.openComment(item)} diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index b633df829..5f6aa59e9 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -9,27 +9,40 @@ describe("promptPlaceholder", () => { mode: "shell", commentCount: 0, example: "example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.shell") }) test("returns summarize placeholders for comment context", () => { - expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComment", ) - expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe( + expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", suggest: true, t })).toBe( "prompt.placeholder.summarizeComments", ) }) - test("returns default placeholder with example", () => { + test("returns default placeholder with example when suggestions enabled", () => { const value = promptPlaceholder({ mode: "normal", commentCount: 0, example: "translated-example", + suggest: true, t, }) expect(value).toBe("prompt.placeholder.normal:translated-example") }) + + test("returns simple placeholder when suggestions disabled", () => { + const value = promptPlaceholder({ + mode: "normal", + commentCount: 0, + example: "translated-example", + suggest: false, + t, + }) + expect(value).toBe("prompt.placeholder.simple") + }) }) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 07f6a43b5..395fee51b 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -2,6 +2,7 @@ type PromptPlaceholderInput = { mode: "normal" | "shell" commentCount: number example: string + suggest: boolean t: (key: string, params?: Record) => string } @@ -9,5 +10,6 @@ export function promptPlaceholder(input: PromptPlaceholderInput) { if (input.mode === "shell") return input.t("prompt.placeholder.shell") if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") + if (!input.suggest) return input.t("prompt.placeholder.simple") return input.t("prompt.placeholder.normal", { example: input.example }) } diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 259883d61..65eb01c79 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -40,9 +40,9 @@ export const PromptPopover: Component = (props) => { ref={(el) => { if (props.popover === "slash") props.setSlashPopoverRef(el) }} - class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10 - overflow-auto no-scrollbar flex flex-col p-2 rounded-md - border border-border-base bg-surface-raised-stronger-non-alpha shadow-md" + class="absolute inset-x-0 -top-2 -translate-y-full origin-bottom-left max-h-80 min-h-10 + overflow-auto no-scrollbar flex flex-col p-2 rounded-[12px] + bg-surface-raised-stronger-non-alpha shadow-[var(--shadow-lg-border-base)]" onMouseDown={(e) => e.preventDefault()} > diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 9a1fba5d5..6b6f4a4e0 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -80,6 +80,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { queued.abort.abort() queued.cleanup() pending.delete(sessionID) + globalSync.todo.set(sessionID, undefined) return Promise.resolve() } return sdk.client.session @@ -87,6 +88,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { sessionID, }) .catch(() => {}) + .finally(() => { + globalSync.todo.set(sessionID, undefined) + }) } const restoreCommentItems = (items: CommentItem[]) => { diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index 5054253b8..1a0bbbe97 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, type Component } from "solid-js" +import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,25 +12,98 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => const language = useLanguage() const questions = createMemo(() => props.request.questions) - const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + const total = createMemo(() => questions().length) const [store, setStore] = createStore({ tab: 0, answers: [] as QuestionAnswer[], custom: [] as string[], + customOn: [] as boolean[], editing: false, sending: false, }) + let root: HTMLDivElement | undefined + const question = createMemo(() => questions()[store.tab]) - const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) const input = createMemo(() => store.custom[store.tab] ?? "") + const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) - const customPicked = createMemo(() => { - const value = input() - if (!value) return false - return store.answers[store.tab]?.includes(value) ?? false + + const summary = createMemo(() => { + const n = Math.min(store.tab + 1, total()) + return `${n} of ${total()} questions` + }) + + const last = createMemo(() => store.tab >= total() - 1) + + const customUpdate = (value: string, selected: boolean = on()) => { + const prev = input().trim() + const next = value.trim() + + setStore("custom", store.tab, value) + if (!selected) return + + if (multi()) { + setStore("answers", store.tab, (current = []) => { + const removed = prev ? current.filter((item) => item.trim() !== prev) : current + if (!next) return removed + if (removed.some((item) => item.trim() === next)) return removed + return [...removed, next] + }) + return + } + + setStore("answers", store.tab, next ? [next] : []) + } + + const measure = () => { + if (!root) return + + const scroller = document.querySelector(".session-scroller") + const head = scroller instanceof HTMLElement ? scroller.firstElementChild : undefined + const top = + head instanceof HTMLElement && head.classList.contains("sticky") ? head.getBoundingClientRect().bottom : 0 + if (!top) { + root.style.removeProperty("--question-prompt-max-height") + return + } + + const dock = root.closest('[data-component="session-prompt-dock"]') + if (!(dock instanceof HTMLElement)) return + + const dockBottom = dock.getBoundingClientRect().bottom + const below = Math.max(0, dockBottom - root.getBoundingClientRect().bottom) + const gap = 8 + const max = Math.max(240, Math.floor(dockBottom - top - gap - below)) + root.style.setProperty("--question-prompt-max-height", `${max}px`) + } + + onMount(() => { + let raf: number | undefined + const update = () => { + if (raf !== undefined) cancelAnimationFrame(raf) + raf = requestAnimationFrame(() => { + raf = undefined + measure() + }) + } + + update() + window.addEventListener("resize", update) + + const dock = root?.closest('[data-component="session-prompt-dock"]') + const scroller = document.querySelector(".session-scroller") + const observer = new ResizeObserver(update) + if (dock instanceof HTMLElement) observer.observe(dock) + if (scroller instanceof HTMLElement) observer.observe(scroller) + + onCleanup(() => { + window.removeEventListener("resize", update) + observer.disconnect() + if (raf !== undefined) cancelAnimationFrame(raf) + }) }) const fail = (err: unknown) => { @@ -64,23 +137,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } } - const submit = () => { - void reply(questions().map((_, i) => store.answers[i] ?? [])) - } + const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) - - if (custom) { - setStore("custom", store.tab, answer) - } - - if (single()) { - void reply([[answer]]) - return - } - - setStore("tab", store.tab + 1) + if (custom) setStore("custom", store.tab, answer) + if (!custom) setStore("customOn", store.tab, false) + setStore("editing", false) } const toggle = (answer: string) => { @@ -90,16 +153,41 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => }) } - const selectTab = (index: number) => { - setStore("tab", index) + const customToggle = () => { + if (store.sending) return + + if (!multi()) { + setStore("customOn", store.tab, true) + setStore("editing", true) + customUpdate(input(), true) + return + } + + const next = !on() + setStore("customOn", store.tab, next) + if (next) { + setStore("editing", true) + customUpdate(input(), true) + return + } + + const value = input().trim() + if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) setStore("editing", false) } + const customOpen = () => { + if (store.sending) return + if (!on()) setStore("customOn", store.tab, true) + setStore("editing", true) + customUpdate(input(), true) + } + const selectOption = (optIndex: number) => { if (store.sending) return if (optIndex === options().length) { - setStore("editing", true) + customOpen() return } @@ -112,67 +200,67 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => pick(opt.label) } - const handleCustomSubmit = (e: Event) => { - e.preventDefault() + const commitCustom = () => { + setStore("editing", false) + customUpdate(input()) + } + + const next = () => { if (store.sending) return + if (store.editing) commitCustom() - const value = input().trim() - if (!value) { - setStore("editing", false) + if (store.tab >= total() - 1) { + submit() return } - if (multi()) { - setStore("answers", store.tab, (current = []) => { - if (current.includes(value)) return current - return [...current, value] - }) - setStore("editing", false) - return - } + setStore("tab", store.tab + 1) + setStore("editing", false) + } - pick(value, true) + const back = () => { + if (store.sending) return + if (store.tab <= 0) return + setStore("tab", store.tab - 1) + setStore("editing", false) + } + + const jump = (tab: number) => { + if (store.sending) return + setStore("tab", tab) setStore("editing", false) } return ( -
- -
- - {(q, index) => { - const active = () => index() === store.tab - const answered = () => (store.answers[index()]?.length ?? 0) > 0 - return ( +
(root = el)}> +
+
+
{summary()}
+
+ + {(_, i) => ( - ) - }} - - -
- - - -
-
- {question()?.question} - {multi() ? " " + language.t("ui.question.multiHint") : ""} + onClick={() => jump(i())} + aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} + /> + )} +
+
+ +
+
{question()?.question}
+ {language.t("ui.question.singleHint")}
}> +
{language.t("ui.question.multiHint")}
+
{(opt, i) => { @@ -181,106 +269,156 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => ) }} - - -
- setTimeout(() => el.focus(), 0)} - type="text" - data-slot="custom-input" - placeholder={language.t("ui.question.custom.placeholder")} - value={input()} + + { - setStore("custom", store.tab, e.currentTarget.value) - }} - /> - - + + + {language.t("ui.messagePart.option.typeOwnAnswer")} + + {input() || language.t("ui.question.custom.placeholder")} + + + + } + > + { + if (store.sending) { + e.preventDefault() + return + } + if (e.target instanceof HTMLTextAreaElement) return + const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(e) => { + e.preventDefault() + commitCustom() + }} + > + + + {language.t("ui.messagePart.option.typeOwnAnswer")} +