diff options
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/prompt-input.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/components/question-dock.tsx | 425 | ||||
| -rw-r--r-- | packages/app/src/components/session-todo-dock.tsx | 208 |
3 files changed, 6 insertions, 639 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 8d97fccea..0777bacc7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -20,6 +20,7 @@ import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" import { Button } from "@opencode-ai/ui/button" +import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import type { IconName } from "@opencode-ai/ui/icons/provider" @@ -1045,12 +1046,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => { commandKeybind={command.keybind} t={(key) => language.t(key as Parameters<typeof language.t>[0])} /> - <form + <DockShellForm onSubmit={handleSubmit} classList={{ "group/prompt-input": true, - "bg-surface-raised-stronger-non-alpha shadow-xs-border relative z-10": true, - "rounded-[12px] overflow-clip focus-within:shadow-xs-border": true, + "focus-within:shadow-xs-border": true, "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, }} @@ -1243,9 +1243,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> </Show> </div> - </form> + </DockShellForm> <Show when={store.mode === "normal" || store.mode === "shell"}> - <div class="-mt-3.5 bg-background-base border border-border-weak-base relative z-0 rounded-[12px] rounded-tl-0 rounded-tr-0 overflow-clip"> + <DockTray attach="top"> <div class="px-2 pt-5.5 pb-2 flex items-center gap-2 min-w-0"> <div class="flex items-center gap-1.5 min-w-0 flex-1"> <Show when={store.mode === "shell"}> @@ -1385,7 +1385,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => { /> </div> </div> - </div> + </DockTray> </Show> </div> ) diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx deleted file mode 100644 index cd2e495b1..000000000 --- a/packages/app/src/components/question-dock.tsx +++ /dev/null @@ -1,425 +0,0 @@ -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 { DockPrompt } from "@opencode-ai/ui/dock-prompt" -import { Icon } from "@opencode-ai/ui/icon" -import { showToast } from "@opencode-ai/ui/toast" -import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" -import { useLanguage } from "@/context/language" -import { useSDK } from "@/context/sdk" - -export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { - const sdk = useSDK() - const language = useLanguage() - - const questions = createMemo(() => props.request.questions) - 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 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 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) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - } - - const reply = async (answers: QuestionAnswer[]) => { - if (store.sending) return - - setStore("sending", true) - try { - await sdk.client.question.reply({ requestID: props.request.id, answers }) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } - } - - const reject = async () => { - if (store.sending) return - - setStore("sending", true) - try { - await sdk.client.question.reject({ requestID: props.request.id }) - } catch (err) { - fail(err) - } finally { - setStore("sending", false) - } - } - - 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 (!custom) setStore("customOn", store.tab, false) - setStore("editing", false) - } - - const toggle = (answer: string) => { - setStore("answers", store.tab, (current = []) => { - if (current.includes(answer)) return current.filter((item) => item !== answer) - return [...current, answer] - }) - } - - 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) { - customOpen() - return - } - - const opt = options()[optIndex] - if (!opt) return - if (multi()) { - toggle(opt.label) - return - } - pick(opt.label) - } - - const commitCustom = () => { - setStore("editing", false) - customUpdate(input()) - } - - const next = () => { - if (store.sending) return - if (store.editing) commitCustom() - - if (store.tab >= total() - 1) { - submit() - return - } - - setStore("tab", store.tab + 1) - setStore("editing", false) - } - - 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 ( - <DockPrompt - kind="question" - ref={(el) => (root = el)} - header={ - <> - <div data-slot="question-header-title">{summary()}</div> - <div data-slot="question-progress"> - <For each={questions()}> - {(_, i) => ( - <button - type="button" - data-slot="question-progress-segment" - data-active={i() === store.tab} - data-answered={ - (store.answers[i()]?.length ?? 0) > 0 || - (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) - } - disabled={store.sending} - onClick={() => jump(i())} - aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} - /> - )} - </For> - </div> - </> - } - footer={ - <> - <Button variant="ghost" size="large" disabled={store.sending} onClick={reject}> - {language.t("ui.common.dismiss")} - </Button> - <div data-slot="question-footer-actions"> - <Show when={store.tab > 0}> - <Button variant="secondary" size="large" disabled={store.sending} onClick={back}> - {language.t("ui.common.back")} - </Button> - </Show> - <Button variant={last() ? "primary" : "secondary"} size="large" disabled={store.sending} onClick={next}> - {last() ? language.t("ui.common.submit") : language.t("ui.common.next")} - </Button> - </div> - </> - } - > - <div data-slot="question-text">{question()?.question}</div> - <Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}> - <div data-slot="question-hint">{language.t("ui.question.multiHint")}</div> - </Show> - <div data-slot="question-options"> - <For each={options()}> - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - <button - data-slot="question-option" - data-picked={picked()} - role={multi() ? "checkbox" : "radio"} - aria-checked={picked()} - disabled={store.sending} - onClick={() => selectOption(i())} - > - <span data-slot="question-option-check" aria-hidden="true"> - <span - data-slot="question-option-box" - data-type={multi() ? "checkbox" : "radio"} - data-picked={picked()} - > - <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> - <Icon name="check-small" size="small" /> - </Show> - </span> - </span> - <span data-slot="question-option-main"> - <span data-slot="option-label">{opt.label}</span> - <Show when={opt.description}> - <span data-slot="option-description">{opt.description}</span> - </Show> - </span> - </button> - ) - }} - </For> - - <Show - when={store.editing} - fallback={ - <button - data-slot="question-option" - data-custom="true" - data-picked={on()} - role={multi() ? "checkbox" : "radio"} - aria-checked={on()} - disabled={store.sending} - onClick={customOpen} - > - <span - data-slot="question-option-check" - aria-hidden="true" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - customToggle() - }} - > - <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}> - <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> - <Icon name="check-small" size="small" /> - </Show> - </span> - </span> - <span data-slot="question-option-main"> - <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> - <span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span> - </span> - </button> - } - > - <form - data-slot="question-option" - data-custom="true" - data-picked={on()} - role={multi() ? "checkbox" : "radio"} - aria-checked={on()} - onMouseDown={(e) => { - 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() - }} - > - <span - data-slot="question-option-check" - aria-hidden="true" - onClick={(e) => { - e.preventDefault() - e.stopPropagation() - customToggle() - }} - > - <span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}> - <Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}> - <Icon name="check-small" size="small" /> - </Show> - </span> - </span> - <span data-slot="question-option-main"> - <span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span> - <textarea - ref={(el) => - setTimeout(() => { - el.focus() - el.style.height = "0px" - el.style.height = `${el.scrollHeight}px` - }, 0) - } - data-slot="question-custom-input" - placeholder={language.t("ui.question.custom.placeholder")} - value={input()} - rows={1} - disabled={store.sending} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault() - setStore("editing", false) - return - } - if (e.key !== "Enter" || e.shiftKey) return - e.preventDefault() - commitCustom() - }} - onInput={(e) => { - customUpdate(e.currentTarget.value) - e.currentTarget.style.height = "0px" - e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px` - }} - /> - </span> - </form> - </Show> - </div> - </DockPrompt> - ) -} diff --git a/packages/app/src/components/session-todo-dock.tsx b/packages/app/src/components/session-todo-dock.tsx deleted file mode 100644 index aeb2e421b..000000000 --- a/packages/app/src/components/session-todo-dock.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import type { Todo } from "@opencode-ai/sdk/v2" -import { Checkbox } from "@opencode-ai/ui/checkbox" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" - -function dot(status: Todo["status"]) { - if (status !== "in_progress") return undefined - return ( - <svg - viewBox="0 0 12 12" - width="12" - height="12" - fill="currentColor" - xmlns="http://www.w3.org/2000/svg" - class="block" - > - <circle - cx="6" - cy="6" - r="3" - style={{ - animation: "var(--animate-pulse-scale)", - "transform-origin": "center", - "transform-box": "fill-box", - }} - /> - </svg> - ) -} - -export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) { - const [store, setStore] = createStore({ - collapsed: false, - }) - - const toggle = () => setStore("collapsed", (value) => !value) - - const summary = createMemo(() => { - const total = props.todos.length - if (total === 0) return "" - const completed = props.todos.filter((todo) => todo.status === "completed").length - return `${completed} of ${total} ${props.title.toLowerCase()} completed` - }) - - const active = createMemo( - () => - props.todos.find((todo) => todo.status === "in_progress") ?? - props.todos.find((todo) => todo.status === "pending") ?? - props.todos.filter((todo) => todo.status === "completed").at(-1) ?? - props.todos[0], - ) - - const preview = createMemo(() => active()?.content ?? "") - - return ( - <div - classList={{ - "bg-background-base border border-border-weak-base relative z-0 rounded-[12px] overflow-clip": true, - "h-[78px]": store.collapsed, - }} - > - <div - class="pl-3 pr-2 py-2 flex items-center gap-2" - role="button" - tabIndex={0} - onClick={toggle} - onKeyDown={(event) => { - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - toggle() - }} - > - <span class="text-14-regular text-text-strong cursor-default">{summary()}</span> - <Show when={store.collapsed}> - <div class="ml-1 flex-1 min-w-0"> - <Show when={preview()}> - <div class="text-14-regular text-text-base truncate cursor-default">{preview()}</div> - </Show> - </div> - </Show> - <div classList={{ "ml-auto": !store.collapsed, "ml-1": store.collapsed }}> - <IconButton - icon="chevron-down" - size="normal" - variant="ghost" - classList={{ "rotate-180": !store.collapsed }} - onMouseDown={(event) => { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - toggle() - }} - aria-label={store.collapsed ? props.expandLabel : props.collapseLabel} - /> - </div> - </div> - - <div hidden={store.collapsed}> - <TodoList todos={props.todos} open={!store.collapsed} /> - </div> - </div> - ) -} - -function TodoList(props: { todos: Todo[]; open: boolean }) { - const [stuck, setStuck] = createSignal(false) - const [scrolling, setScrolling] = createSignal(false) - let scrollRef!: HTMLDivElement - let timer: number | undefined - - const inProgress = createMemo(() => props.todos.findIndex((todo) => todo.status === "in_progress")) - - const ensure = () => { - if (!props.open) return - if (scrolling()) return - if (!scrollRef || scrollRef.offsetParent === null) return - - const el = scrollRef.querySelector("[data-in-progress]") - if (!(el instanceof HTMLElement)) return - - const topFade = 16 - const bottomFade = 44 - const container = scrollRef.getBoundingClientRect() - const rect = el.getBoundingClientRect() - const top = rect.top - container.top + scrollRef.scrollTop - const bottom = rect.bottom - container.top + scrollRef.scrollTop - const viewTop = scrollRef.scrollTop + topFade - const viewBottom = scrollRef.scrollTop + scrollRef.clientHeight - bottomFade - - if (top < viewTop) { - scrollRef.scrollTop = Math.max(0, top - topFade) - } else if (bottom > viewBottom) { - scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) - } - - setStuck(scrollRef.scrollTop > 0) - } - - createEffect( - on([() => props.open, inProgress], () => { - if (!props.open || inProgress() < 0) return - requestAnimationFrame(ensure) - }), - ) - - onCleanup(() => { - if (!timer) return - window.clearTimeout(timer) - }) - - return ( - <div class="relative"> - <div - class="px-3 pb-11 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar" - ref={scrollRef} - style={{ "overflow-anchor": "none" }} - onScroll={(e) => { - setStuck(e.currentTarget.scrollTop > 0) - setScrolling(true) - if (timer) window.clearTimeout(timer) - timer = window.setTimeout(() => { - setScrolling(false) - if (inProgress() < 0) return - requestAnimationFrame(ensure) - }, 250) - }} - > - <For each={props.todos}> - {(todo) => ( - <Checkbox - readOnly - checked={todo.status === "completed"} - indeterminate={todo.status === "in_progress"} - data-in-progress={todo.status === "in_progress" ? "" : undefined} - icon={dot(todo.status)} - style={{ "--checkbox-align": "flex-start", "--checkbox-offset": "1px" }} - > - <span - class="text-14-regular min-w-0 break-words" - classList={{ - "text-text-weak": todo.status === "completed" || todo.status === "cancelled", - "text-text-strong": todo.status !== "completed" && todo.status !== "cancelled", - }} - style={{ - "line-height": "var(--line-height-normal)", - "text-decoration": - todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined, - }} - > - {todo.content} - </span> - </Checkbox> - )} - </For> - </div> - <div - class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" - style={{ - background: "linear-gradient(to bottom, var(--background-base), transparent)", - opacity: stuck() ? 1 : 0, - }} - /> - </div> - ) -} |
