summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/prompt-input.tsx12
-rw-r--r--packages/app/src/components/question-dock.tsx425
-rw-r--r--packages/app/src/components/session-todo-dock.tsx208
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>
- )
-}