diff options
| author | Adam <[email protected]> | 2026-02-19 15:02:45 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-19 15:02:45 -0600 |
| commit | 49cc872c4415f081b4208d16fd0d85e425a75eed (patch) | |
| tree | ed78239c2a2bb941c5008f378a00592f5b10717a /packages/app/src/pages | |
| parent | f8dad0ae170acb9667d9402c162f7c29980373c1 (diff) | |
| download | opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.tar.gz opencode-49cc872c4415f081b4208d16fd0d85e425a75eed.zip | |
chore: refactor composer/dock components (#14328)
Diffstat (limited to 'packages/app/src/pages')
8 files changed, 1000 insertions, 326 deletions
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 21ba4e7d7..496f0487d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -27,7 +27,7 @@ import { SessionReviewTab, type DiffStyle, type SessionReviewTabProps } from "@/ import { TerminalPanel } from "@/pages/session/terminal-panel" import { MessageTimeline } from "@/pages/session/message-timeline" import { useSessionCommands } from "@/pages/session/use-session-commands" -import { SessionPromptDock } from "@/pages/session/session-prompt-dock" +import { SessionComposerRegion, createSessionComposerState } from "@/pages/session/composer" import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" @@ -54,11 +54,7 @@ export default function Page() { }, }) - const blocked = createMemo(() => { - const sessionID = params.id - if (!sessionID) return false - return !!sync.data.permission[sessionID]?.[0] || !!sync.data.question[sessionID]?.[0] - }) + const composer = createSessionComposerState() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const workspaceKey = createMemo(() => params.dir ?? "") @@ -401,7 +397,7 @@ export default function Page() { } if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { - if (blocked()) return + if (composer.blocked()) return inputRef?.focus() } } @@ -1090,7 +1086,8 @@ export default function Page() { </Switch> </div> - <SessionPromptDock + <SessionComposerRegion + state={composer} centered={centered()} inputRef={(el) => { inputRef = el diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts new file mode 100644 index 000000000..e244a1536 --- /dev/null +++ b/packages/app/src/pages/session/composer/index.ts @@ -0,0 +1,3 @@ +export { SessionComposerRegion } from "./session-composer-region" +export { createSessionComposerBlocked, createSessionComposerState } from "./session-composer-state" +export type { SessionComposerState } from "./session-composer-state" diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx new file mode 100644 index 000000000..ccf39f797 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -0,0 +1,124 @@ +import { Show, createEffect, createMemo } from "solid-js" +import { useParams } from "@solidjs/router" +import { PromptInput } from "@/components/prompt-input" +import { useLanguage } from "@/context/language" +import { usePrompt } from "@/context/prompt" +import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" +import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock" +import { SessionQuestionDock } from "@/pages/session/composer/session-question-dock" +import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" +import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" + +export function SessionComposerRegion(props: { + state: SessionComposerState + centered: boolean + inputRef: (el: HTMLDivElement) => void + newSessionWorktree: string + onNewSessionWorktreeReset: () => void + onSubmit: () => void + setPromptDockRef: (el: HTMLDivElement) => void +}) { + const params = useParams() + const prompt = usePrompt() + const language = useLanguage() + + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) + + const previewPrompt = () => + prompt + .current() + .map((part) => { + if (part.type === "file") return `[file:${part.path}]` + if (part.type === "agent") return `@${part.name}` + if (part.type === "image") return `[image:${part.filename}]` + return part.content + }) + .join("") + .trim() + + createEffect(() => { + if (!prompt.ready()) return + setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) + }) + + return ( + <div + ref={props.setPromptDockRef} + data-component="session-prompt-dock" + class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none" + > + <div + classList={{ + "w-full px-3 pointer-events-auto": true, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, + }} + > + <Show when={props.state.questionRequest()} keyed> + {(request) => ( + <div> + <SessionQuestionDock request={request} /> + </div> + )} + </Show> + + <Show when={props.state.permissionRequest()} keyed> + {(request) => ( + <div> + <SessionPermissionDock + request={request} + responding={props.state.permissionResponding()} + onDecide={props.state.decide} + /> + </div> + )} + </Show> + + <Show when={!props.state.blocked()}> + <Show + when={prompt.ready()} + fallback={ + <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> + {handoffPrompt() || language.t("prompt.loading")} + </div> + } + > + <Show when={props.state.dock()}> + <div + classList={{ + "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true, + "max-h-[320px]": !props.state.closing(), + "max-h-0 pointer-events-none": props.state.closing(), + "opacity-0 translate-y-9": props.state.closing() || props.state.opening(), + "opacity-100 translate-y-0": !props.state.closing() && !props.state.opening(), + }} + > + <SessionTodoDock + todos={props.state.todos()} + title={language.t("session.todo.title")} + collapseLabel={language.t("session.todo.collapse")} + expandLabel={language.t("session.todo.expand")} + /> + </div> + </Show> + <div + classList={{ + "relative z-10": true, + "transition-[margin] duration-[400ms] ease-out": true, + "-mt-9": props.state.dock() && !props.state.closing(), + "mt-0": !props.state.dock() || props.state.closing(), + }} + > + <PromptInput + ref={props.inputRef} + newSessionWorktree={props.newSessionWorktree} + onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} + onSubmit={props.onSubmit} + /> + </div> + </Show> + </Show> + </div> + </div> + ) +} diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts new file mode 100644 index 000000000..04c6f7e69 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -0,0 +1,158 @@ +import { createEffect, createMemo, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" +import { useParams } from "@solidjs/router" +import { showToast } from "@opencode-ai/ui/toast" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" + +export function createSessionComposerBlocked() { + const params = useParams() + const sync = useSync() + return createMemo(() => { + const id = params.id + if (!id) return false + return !!sync.data.permission[id]?.[0] || !!sync.data.question[id]?.[0] + }) +} + +export function createSessionComposerState() { + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const globalSync = useGlobalSync() + const language = useLanguage() + + const questionRequest = createMemo((): QuestionRequest | undefined => { + const id = params.id + if (!id) return + return sync.data.question[id]?.[0] + }) + + const permissionRequest = createMemo((): PermissionRequest | undefined => { + const id = params.id + if (!id) return + return sync.data.permission[id]?.[0] + }) + + const blocked = createSessionComposerBlocked() + + const todos = createMemo((): Todo[] => { + const id = params.id + if (!id) return [] + return globalSync.data.session_todo[id] ?? [] + }) + + const [store, setStore] = createStore({ + responding: undefined as string | undefined, + dock: todos().length > 0, + closing: false, + opening: false, + }) + + const permissionResponding = createMemo(() => { + const perm = permissionRequest() + if (!perm) return false + return store.responding === perm.id + }) + + const decide = (response: "once" | "always" | "reject") => { + const perm = permissionRequest() + if (!perm) return + if (store.responding === perm.id) return + + setStore("responding", perm.id) + sdk.client.permission + .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .catch((err: unknown) => { + const description = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description }) + }) + .finally(() => { + setStore("responding", (id) => (id === perm.id ? undefined : id)) + }) + } + + const done = createMemo( + () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), + ) + + let timer: number | undefined + let raf: number | undefined + + const scheduleClose = () => { + if (timer) window.clearTimeout(timer) + timer = window.setTimeout(() => { + setStore({ dock: false, closing: false }) + timer = undefined + }, 400) + } + + createEffect( + on( + () => [todos().length, done()] as const, + ([count, complete], prev) => { + if (raf) cancelAnimationFrame(raf) + raf = undefined + + if (count === 0) { + if (timer) window.clearTimeout(timer) + timer = undefined + setStore({ dock: false, closing: false, opening: false }) + return + } + + if (!complete) { + if (timer) window.clearTimeout(timer) + timer = undefined + const hidden = !store.dock || store.closing + setStore({ dock: true, closing: false }) + if (hidden) { + setStore("opening", true) + raf = requestAnimationFrame(() => { + setStore("opening", false) + raf = undefined + }) + return + } + setStore("opening", false) + return + } + + if (prev && prev[1]) { + if (store.closing && !timer) scheduleClose() + return + } + + setStore({ dock: true, opening: false, closing: true }) + scheduleClose() + }, + ), + ) + + onCleanup(() => { + if (!timer) return + window.clearTimeout(timer) + }) + + onCleanup(() => { + if (!raf) return + cancelAnimationFrame(raf) + }) + + return { + blocked, + questionRequest, + permissionRequest, + permissionResponding, + decide, + todos, + dock: () => store.dock, + closing: () => store.closing, + opening: () => store.opening, + } +} + +export type SessionComposerState = ReturnType<typeof createSessionComposerState> diff --git a/packages/app/src/pages/session/composer/session-permission-dock.tsx b/packages/app/src/pages/session/composer/session-permission-dock.tsx new file mode 100644 index 000000000..06ff4f4aa --- /dev/null +++ b/packages/app/src/pages/session/composer/session-permission-dock.tsx @@ -0,0 +1,74 @@ +import { For, Show } from "solid-js" +import type { PermissionRequest } from "@opencode-ai/sdk/v2" +import { Button } from "@opencode-ai/ui/button" +import { DockPrompt } from "@opencode-ai/ui/dock-prompt" +import { Icon } from "@opencode-ai/ui/icon" +import { useLanguage } from "@/context/language" + +export function SessionPermissionDock(props: { + request: PermissionRequest + responding: boolean + onDecide: (response: "once" | "always" | "reject") => void +}) { + const language = useLanguage() + + const toolDescription = () => { + const key = `settings.permissions.tool.${props.request.permission}.description` + const value = language.t(key as Parameters<typeof language.t>[0]) + if (value === key) return "" + return value + } + + return ( + <DockPrompt + kind="permission" + header={ + <div data-slot="permission-row" data-variant="header"> + <span data-slot="permission-icon"> + <Icon name="warning" size="normal" /> + </span> + <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div> + </div> + } + footer={ + <> + <div /> + <div data-slot="permission-footer-actions"> + <Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}> + {language.t("ui.permission.deny")} + </Button> + <Button + variant="secondary" + size="normal" + onClick={() => props.onDecide("always")} + disabled={props.responding} + > + {language.t("ui.permission.allowAlways")} + </Button> + <Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}> + {language.t("ui.permission.allowOnce")} + </Button> + </div> + </> + } + > + <Show when={toolDescription()}> + <div data-slot="permission-row"> + <span data-slot="permission-spacer" aria-hidden="true" /> + <div data-slot="permission-hint">{toolDescription()}</div> + </div> + </Show> + + <Show when={props.request.patterns.length > 0}> + <div data-slot="permission-row"> + <span data-slot="permission-spacer" aria-hidden="true" /> + <div data-slot="permission-patterns"> + <For each={props.request.patterns}> + {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>} + </For> + </div> + </div> + </Show> + </DockPrompt> + ) +} diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx new file mode 100644 index 000000000..97c81a49a --- /dev/null +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -0,0 +1,425 @@ +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 SessionQuestionDock: 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/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx new file mode 100644 index 000000000..ca7a5abd1 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -0,0 +1,211 @@ +import type { Todo } from "@opencode-ai/sdk/v2" +import { Checkbox } from "@opencode-ai/ui/checkbox" +import { DockTray } from "@opencode-ai/ui/dock-surface" +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 ( + <DockTray + data-component="session-todo-dock" + classList={{ + "h-[78px]": store.collapsed, + }} + > + <div + data-action="session-todo-toggle" + 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 + data-action="session-todo-toggle-button" + 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 data-slot="session-todo-list" hidden={store.collapsed}> + <TodoList todos={props.todos} open={!store.collapsed} /> + </div> + </DockTray> + ) +} + +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> + ) +} diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx deleted file mode 100644 index 0e0d06071..000000000 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ /dev/null @@ -1,318 +0,0 @@ -import { For, Show, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" -import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" -import { useParams } from "@solidjs/router" -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 { PromptInput } from "@/components/prompt-input" -import { QuestionDock } from "@/components/question-dock" -import { SessionTodoDock } from "@/components/session-todo-dock" -import { useGlobalSync } from "@/context/global-sync" -import { useLanguage } from "@/context/language" -import { usePrompt } from "@/context/prompt" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff" - -export function SessionPromptDock(props: { - centered: boolean - inputRef: (el: HTMLDivElement) => void - newSessionWorktree: string - onNewSessionWorktreeReset: () => void - onSubmit: () => void - setPromptDockRef: (el: HTMLDivElement) => void -}) { - const params = useParams() - const sdk = useSDK() - const sync = useSync() - const globalSync = useGlobalSync() - const prompt = usePrompt() - const language = useLanguage() - - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const handoffPrompt = createMemo(() => getSessionHandoff(sessionKey())?.prompt) - - const todos = createMemo((): Todo[] => { - const id = params.id - if (!id) return [] - return globalSync.data.session_todo[id] ?? [] - }) - - const questionRequest = createMemo((): QuestionRequest | undefined => { - const sessionID = params.id - if (!sessionID) return - return sync.data.question[sessionID]?.[0] - }) - - const permissionRequest = createMemo((): PermissionRequest | undefined => { - const sessionID = params.id - if (!sessionID) return - return sync.data.permission[sessionID]?.[0] - }) - - const blocked = createMemo(() => !!permissionRequest() || !!questionRequest()) - - const previewPrompt = () => - prompt - .current() - .map((part) => { - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - if (part.type === "image") return `[image:${part.filename}]` - return part.content - }) - .join("") - .trim() - - createEffect(() => { - if (!prompt.ready()) return - setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) - }) - - const [responding, setResponding] = createSignal<string | undefined>() - const permissionResponding = () => { - const perm = permissionRequest() - if (!perm) return false - return responding() === perm.id - } - - const decide = (response: "once" | "always" | "reject") => { - const perm = permissionRequest() - if (!perm) return - if (responding() === perm.id) return - - setResponding(perm.id) - sdk.client.permission - .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => { - setResponding((id) => (id === perm.id ? undefined : id)) - }) - } - - const done = createMemo( - () => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"), - ) - - const [dock, setDock] = createSignal(todos().length > 0) - const [closing, setClosing] = createSignal(false) - const [opening, setOpening] = createSignal(false) - let timer: number | undefined - let raf: number | undefined - - const scheduleClose = () => { - if (timer) window.clearTimeout(timer) - timer = window.setTimeout(() => { - setDock(false) - setClosing(false) - timer = undefined - }, 400) - } - - createEffect( - on( - () => [todos().length, done()] as const, - ([count, complete], prev) => { - if (raf) cancelAnimationFrame(raf) - raf = undefined - - if (count === 0) { - if (timer) window.clearTimeout(timer) - timer = undefined - setDock(false) - setClosing(false) - setOpening(false) - return - } - - if (!complete) { - if (timer) window.clearTimeout(timer) - timer = undefined - const wasHidden = !dock() || closing() - setDock(true) - setClosing(false) - if (wasHidden) { - setOpening(true) - raf = requestAnimationFrame(() => { - setOpening(false) - raf = undefined - }) - return - } - setOpening(false) - return - } - - if (prev && prev[1]) { - if (closing() && !timer) scheduleClose() - return - } - - setDock(true) - setOpening(false) - setClosing(true) - scheduleClose() - }, - ), - ) - - onCleanup(() => { - if (!timer) return - window.clearTimeout(timer) - }) - - onCleanup(() => { - if (!raf) return - cancelAnimationFrame(raf) - }) - - return ( - <div - ref={props.setPromptDockRef} - data-component="session-prompt-dock" - class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none" - > - <div - classList={{ - "w-full px-3 pointer-events-auto": true, - "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, - }} - > - <Show when={questionRequest()} keyed> - {(req) => { - return ( - <div> - <QuestionDock request={req} /> - </div> - ) - }} - </Show> - - <Show when={permissionRequest()} keyed> - {(perm) => { - const toolDescription = () => { - const key = `settings.permissions.tool.${perm.permission}.description` - const value = language.t(key as Parameters<typeof language.t>[0]) - if (value === key) return "" - return value - } - - return ( - <div> - <DockPrompt - kind="permission" - header={ - <div data-slot="permission-row" data-variant="header"> - <span data-slot="permission-icon"> - <Icon name="warning" size="normal" /> - </span> - <div data-slot="permission-header-title">{language.t("notification.permission.title")}</div> - </div> - } - footer={ - <> - <div /> - <div data-slot="permission-footer-actions"> - <Button - variant="ghost" - size="normal" - onClick={() => decide("reject")} - disabled={permissionResponding()} - > - {language.t("ui.permission.deny")} - </Button> - <Button - variant="secondary" - size="normal" - onClick={() => decide("always")} - disabled={permissionResponding()} - > - {language.t("ui.permission.allowAlways")} - </Button> - <Button - variant="primary" - size="normal" - onClick={() => decide("once")} - disabled={permissionResponding()} - > - {language.t("ui.permission.allowOnce")} - </Button> - </div> - </> - } - > - <Show when={toolDescription()}> - <div data-slot="permission-row"> - <span data-slot="permission-spacer" aria-hidden="true" /> - <div data-slot="permission-hint">{toolDescription()}</div> - </div> - </Show> - - <Show when={perm.patterns.length > 0}> - <div data-slot="permission-row"> - <span data-slot="permission-spacer" aria-hidden="true" /> - <div data-slot="permission-patterns"> - <For each={perm.patterns}> - {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>} - </For> - </div> - </div> - </Show> - </DockPrompt> - </div> - ) - }} - </Show> - - <Show when={!blocked()}> - <Show - when={prompt.ready()} - fallback={ - <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> - {handoffPrompt() || language.t("prompt.loading")} - </div> - } - > - <Show when={dock()}> - <div - classList={{ - "transition-[max-height,opacity,transform] duration-[400ms] ease-out overflow-hidden": true, - "max-h-[320px]": !closing(), - "max-h-0 pointer-events-none": closing(), - "opacity-0 translate-y-9": closing() || opening(), - "opacity-100 translate-y-0": !closing() && !opening(), - }} - > - <SessionTodoDock - todos={todos()} - title={language.t("session.todo.title")} - collapseLabel={language.t("session.todo.collapse")} - expandLabel={language.t("session.todo.expand")} - /> - </div> - </Show> - <div - classList={{ - "relative z-10": true, - "transition-[margin] duration-[400ms] ease-out": true, - "-mt-9": dock() && !closing(), - "mt-0": !dock() || closing(), - }} - > - <PromptInput - ref={props.inputRef} - newSessionWorktree={props.newSessionWorktree} - onNewSessionWorktreeReset={props.onNewSessionWorktreeReset} - onSubmit={props.onSubmit} - /> - </div> - </Show> - </Show> - </div> - </div> - ) -} |
