From 4795806b135058febf89acc5a2202a3c24543399 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 6 Mar 2026 16:34:10 -0600 Subject: [PATCH] feat(ui): tool call abstraction, turn diff summary, and composer improvements Co-Authored-By: Claude Opus 4.6 --- bun.lock | 3 +- packages/app/e2e/actions.ts | 15 +- packages/app/e2e/selectors.ts | 2 + packages/app/e2e/session/session.spec.ts | 4 +- packages/app/src/components/prompt-input.tsx | 80 +- .../app/src/components/prompt-input/submit.ts | 31 +- .../session/session-context-tab.tsx | 3 +- .../app/src/components/settings-general.tsx | 23 - packages/app/src/context/command.tsx | 9 +- packages/app/src/context/global-sync.tsx | 1 - packages/app/src/context/language.tsx | 2 - packages/app/src/context/layout.tsx | 2 +- .../context/permission-auto-respond.test.ts | 41 +- .../src/context/permission-auto-respond.ts | 12 +- packages/app/src/context/permission.tsx | 75 +- packages/app/src/context/settings.tsx | 18 - packages/app/src/i18n/ar.ts | 6 - packages/app/src/i18n/br.ts | 6 - packages/app/src/i18n/bs.ts | 6 - packages/app/src/i18n/da.ts | 5 - packages/app/src/i18n/de.ts | 6 - packages/app/src/i18n/en.ts | 7 - packages/app/src/i18n/es.ts | 6 - packages/app/src/i18n/fr.ts | 6 - packages/app/src/i18n/ja.ts | 6 - packages/app/src/i18n/ko.ts | 6 - packages/app/src/i18n/no.ts | 5 - packages/app/src/i18n/pl.ts | 6 - packages/app/src/i18n/ru.ts | 6 - packages/app/src/i18n/th.ts | 5 - packages/app/src/i18n/tr.ts | 7 - packages/app/src/i18n/zh.ts | 4 - packages/app/src/i18n/zht.ts | 4 - packages/app/src/pages/layout.tsx | 77 +- packages/app/src/pages/session.tsx | 263 ++- .../app/src/pages/session/composer/index.ts | 26 + .../composer/session-composer-region.tsx | 119 +- .../composer/session-composer-state.ts | 6 +- .../session/composer/session-todo-dock.tsx | 92 +- .../src/pages/session/message-gesture.test.ts | 4 +- .../app/src/pages/session/message-gesture.ts | 8 +- .../src/pages/session/message-timeline.tsx | 505 ++--- .../src/pages/session/session-side-panel.tsx | 4 +- .../pages/session/session-timeline-header.tsx | 522 +++++ .../app/src/pages/session/terminal-panel.tsx | 16 +- .../pages/session/use-session-commands.tsx | 29 +- .../pages/session/use-session-hash-scroll.ts | 52 +- packages/app/src/utils/dom.ts | 9 + packages/app/src/utils/same.ts | 6 - .../.storybook/mocks/app/context/command.ts | 4 +- packages/ui/happydom.ts | 3 + packages/ui/package.json | 4 + .../ui/src/components/animated-number.css | 11 +- .../ui/src/components/animated-number.tsx | 29 +- .../src/components/animation-debug-panel.tsx | 122 ++ packages/ui/src/components/basic-tool.css | 59 +- .../ui/src/components/basic-tool.stories.tsx | 133 -- packages/ui/src/components/basic-tool.tsx | 356 +++- packages/ui/src/components/collapsible.css | 55 +- .../components/composer-island.stories.tsx | 311 +++ .../ui/src/components/composer-island.tsx | 28 + .../src/components/context-tool-results.tsx | 290 +++ packages/ui/src/components/grow-box.tsx | 426 ++++ packages/ui/src/components/markdown.tsx | 17 +- packages/ui/src/components/message-part.css | 404 +++- packages/ui/src/components/message-part.tsx | 1855 ++++++++--------- packages/ui/src/components/motion-spring.tsx | 32 +- packages/ui/src/components/motion.tsx | 77 + .../src/components/new-composer.stories.tsx | 451 ++++ packages/ui/src/components/new-composer.tsx | 1097 ++++++++++ .../src/components/new-composer/defaults.ts | 64 + .../components/new-composer/drag-overlay.tsx | 23 + .../new-composer/editor-utils.test.ts | 100 + .../components/new-composer/editor-utils.ts | 206 ++ .../components/new-composer/history.test.ts | 128 ++ .../ui/src/components/new-composer/history.ts | 189 ++ .../components/new-composer/input-layer.tsx | 223 ++ .../components/new-composer/model-picker.tsx | 242 +++ .../new-composer/permission-body.tsx | 41 + .../src/components/new-composer/popover.tsx | 111 + .../components/new-composer/question-body.tsx | 218 ++ .../src/components/new-composer/todo-tray.tsx | 302 +++ .../ui/src/components/new-composer/tray.tsx | 410 ++++ .../ui/src/components/new-composer/types.ts | 157 ++ .../src/components/new-composer/use-editor.ts | 722 +++++++ .../src/components/new-composer/use-layout.ts | 87 + .../src/components/new-composer/use-todo.ts | 76 + .../ui/src/components/rolling-results.css | 92 + .../ui/src/components/rolling-results.tsx | 316 +++ packages/ui/src/components/scroll-view.css | 15 +- packages/ui/src/components/scroll-view.tsx | 66 +- packages/ui/src/components/session-review.tsx | 2 +- .../session-timeline-simulator.stories.tsx | 1392 +++++++++++++ packages/ui/src/components/session-turn.css | 127 +- packages/ui/src/components/session-turn.tsx | 556 +++-- .../src/components/shell-rolling-results.tsx | 249 +++ .../shell-submessage-motion.stories.tsx | 329 --- .../ui/src/components/shell-submessage.css | 12 +- packages/ui/src/components/text-reveal.css | 61 +- .../ui/src/components/text-reveal.stories.tsx | 310 --- packages/ui/src/components/text-reveal.tsx | 107 +- packages/ui/src/components/text-shimmer.css | 17 +- .../src/components/text-shimmer.stories.tsx | 92 - packages/ui/src/components/text-shimmer.tsx | 15 + packages/ui/src/components/text-utils.ts | 17 + .../components/todo-panel-motion.stories.tsx | 584 ------ .../ui/src/components/tool-count-label.css | 6 +- .../ui/src/components/tool-count-label.tsx | 21 +- .../ui/src/components/tool-count-summary.css | 22 +- .../ui/src/components/tool-status-title.css | 7 +- .../ui/src/components/tool-status-title.tsx | 67 +- packages/ui/src/components/tool-utils.ts | 154 ++ packages/ui/src/hooks/create-auto-scroll.tsx | 257 ++- packages/ui/src/hooks/index.ts | 3 + packages/ui/src/hooks/use-element-height.ts | 25 + packages/ui/src/hooks/use-page-visible.ts | 11 + packages/ui/src/hooks/use-reduced-motion.ts | 9 + packages/ui/src/i18n/ar.ts | 3 +- packages/ui/src/i18n/br.ts | 3 +- packages/ui/src/i18n/bs.ts | 3 +- packages/ui/src/i18n/da.ts | 3 +- packages/ui/src/i18n/de.ts | 3 +- packages/ui/src/i18n/en.ts | 39 +- packages/ui/src/i18n/es.ts | 3 +- packages/ui/src/i18n/fr.ts | 3 +- packages/ui/src/i18n/ja.ts | 3 +- packages/ui/src/i18n/ko.ts | 3 +- packages/ui/src/i18n/no.ts | 3 +- packages/ui/src/i18n/pl.ts | 3 +- packages/ui/src/i18n/ru.ts | 3 +- packages/ui/src/i18n/th.ts | 3 +- packages/ui/src/i18n/tr.ts | 3 +- packages/ui/src/i18n/zh.ts | 3 +- packages/ui/src/i18n/zht.ts | 3 +- packages/ui/src/styles/index.css | 1 + packages/util/src/array.ts | 7 + 136 files changed, 11986 insertions(+), 4269 deletions(-) create mode 100644 packages/app/src/pages/session/session-timeline-header.tsx delete mode 100644 packages/app/src/utils/same.ts create mode 100644 packages/ui/happydom.ts create mode 100644 packages/ui/src/components/animation-debug-panel.tsx delete mode 100644 packages/ui/src/components/basic-tool.stories.tsx create mode 100644 packages/ui/src/components/composer-island.stories.tsx create mode 100644 packages/ui/src/components/composer-island.tsx create mode 100644 packages/ui/src/components/context-tool-results.tsx create mode 100644 packages/ui/src/components/grow-box.tsx create mode 100644 packages/ui/src/components/motion.tsx create mode 100644 packages/ui/src/components/new-composer.stories.tsx create mode 100644 packages/ui/src/components/new-composer.tsx create mode 100644 packages/ui/src/components/new-composer/defaults.ts create mode 100644 packages/ui/src/components/new-composer/drag-overlay.tsx create mode 100644 packages/ui/src/components/new-composer/editor-utils.test.ts create mode 100644 packages/ui/src/components/new-composer/editor-utils.ts create mode 100644 packages/ui/src/components/new-composer/history.test.ts create mode 100644 packages/ui/src/components/new-composer/history.ts create mode 100644 packages/ui/src/components/new-composer/input-layer.tsx create mode 100644 packages/ui/src/components/new-composer/model-picker.tsx create mode 100644 packages/ui/src/components/new-composer/permission-body.tsx create mode 100644 packages/ui/src/components/new-composer/popover.tsx create mode 100644 packages/ui/src/components/new-composer/question-body.tsx create mode 100644 packages/ui/src/components/new-composer/todo-tray.tsx create mode 100644 packages/ui/src/components/new-composer/tray.tsx create mode 100644 packages/ui/src/components/new-composer/types.ts create mode 100644 packages/ui/src/components/new-composer/use-editor.ts create mode 100644 packages/ui/src/components/new-composer/use-layout.ts create mode 100644 packages/ui/src/components/new-composer/use-todo.ts create mode 100644 packages/ui/src/components/rolling-results.css create mode 100644 packages/ui/src/components/rolling-results.tsx create mode 100644 packages/ui/src/components/session-timeline-simulator.stories.tsx create mode 100644 packages/ui/src/components/shell-rolling-results.tsx delete mode 100644 packages/ui/src/components/shell-submessage-motion.stories.tsx delete mode 100644 packages/ui/src/components/text-reveal.stories.tsx delete mode 100644 packages/ui/src/components/text-shimmer.stories.tsx create mode 100644 packages/ui/src/components/text-utils.ts delete mode 100644 packages/ui/src/components/todo-panel-motion.stories.tsx create mode 100644 packages/ui/src/components/tool-utils.ts create mode 100644 packages/ui/src/hooks/use-element-height.ts create mode 100644 packages/ui/src/hooks/use-page-visible.ts create mode 100644 packages/ui/src/hooks/use-reduced-motion.ts diff --git a/bun.lock b/bun.lock index 97292974d2..f73e9b2b0d 100644 --- a/bun.lock +++ b/bun.lock @@ -506,6 +506,7 @@ "virtua": "catalog:", }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -2962,7 +2963,7 @@ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], - "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d", "sha512-fbEK8mtr7ar4ySsF+JUGjhaZrane7dKphanN+SxHt5XXI6yLMAh/Hpf6sNCOyyVa2UlGCd7YpXG/T2v2RUAX+A=="], + "ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#4af877d", {}, "anomalyco-ghostty-web-4af877d"], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 919a1add81..852bef0543 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -8,6 +8,7 @@ import { sessionItemSelector, dropdownMenuTriggerSelector, dropdownMenuContentSelector, + sessionHeaderSelector, projectMenuTriggerSelector, projectWorkspacesToggleSelector, titlebarRightSelector, @@ -197,7 +198,6 @@ export async function createTestProject() { await fs.writeFile(path.join(root, "README.md"), "# e2e\n") execSync("git init", { cwd: root, stdio: "ignore" }) - execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" }) execSync("git add -A", { cwd: root, stdio: "ignore" }) execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', { cwd: root, @@ -208,10 +208,7 @@ export async function createTestProject() { } export async function cleanupTestProject(directory: string) { - try { - execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" }) - } catch {} - await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined) + await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined) } export function sessionIDFromUrl(url: string) { @@ -229,9 +226,9 @@ export async function hoverSessionItem(page: Page, sessionID: string) { export async function openSessionMoreMenu(page: Page, sessionID: string) { await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`)) - const scroller = page.locator(".scroll-view__viewport").first() - await expect(scroller).toBeVisible() - await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) + const header = page.locator(sessionHeaderSelector).first() + await expect(header).toBeVisible() + await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 }) const menu = page .locator(dropdownMenuContentSelector) @@ -247,7 +244,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) { if (opened) return menu - const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first() + const menuTrigger = header.getByRole("button", { name: /more options/i }).first() await expect(menuTrigger).toBeVisible() await menuTrigger.click() diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b5..d546cc668e 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -53,6 +53,8 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte export const inlineInputSelector = '[data-component="inline-input"]' +export const sessionHeaderSelector = "[data-session-title]" + export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]` export const workspaceItemSelector = (slug: string) => diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 68d9929499..afbea91ca6 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -7,7 +7,7 @@ import { openSharePopover, withSession, } from "../actions" -import { sessionItemSelector, inlineInputSelector } from "../selectors" +import { sessionHeaderSelector, sessionItemSelector, inlineInputSelector } from "../selectors" const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" @@ -44,7 +44,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession } const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) - const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first() + const input = page.locator(sessionHeaderSelector).locator(inlineInputSelector).first() await expect(input).toBeVisible() await expect(input).toBeFocused() await input.fill(renamedTitle) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 532edd3bcd..7bd18fdff0 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -244,6 +244,7 @@ export const PromptInput: Component = (props) => { draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean + pendingAutoAccept: boolean }>({ popover: null, historyIndex: -1, @@ -252,9 +253,20 @@ export const PromptInput: Component = (props) => { draggingType: null, mode: "normal", applyingHistory: false, + pendingAutoAccept: false, }) - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) + const buttonsSpring = useSpring( + () => (store.mode === "normal" ? 1 : 0), + { visualDuration: 0.2, bounce: 0 }, + ) + + const springFade = (t: number): Record => ({ + opacity: `${t}`, + transform: `scale(${0.95 + t * 0.05})`, + filter: `blur(${(1 - t) * 2}px)`, + "pointer-events": t > 0.5 ? "auto" : "none", + }) const commentCount = createMemo(() => { if (store.mode === "shell") return 0 @@ -304,6 +316,12 @@ export const PromptInput: Component = (props) => { }), ) + createEffect( + on(sessionKey, () => { + setStore("pendingAutoAccept", false) + }), + ) + const historyComments = () => { const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) return prompt.context.items().flatMap((item) => { @@ -953,7 +971,7 @@ export const PromptInput: Component = (props) => { const variants = createMemo(() => ["default", ...local.model.variant.list()]) const accepting = createMemo(() => { const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) + if (!id) return store.pendingAutoAccept return permission.isAutoAccepting(id, sdk.directory) }) @@ -1246,9 +1264,7 @@ export const PromptInput: Component = (props) => {
0.5 ? "auto" : "none", - }} + style={{ "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none" }} > = (props) => { type="button" variant="ghost" class="size-8 p-0" - style={{ - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - }} + style={springFade(buttonsSpring())} onClick={pick} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} @@ -1302,11 +1314,7 @@ export const PromptInput: Component = (props) => { icon={working() ? "stop" : "arrow-up"} variant="primary" class="size-8" - style={{ - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - }} + style={springFade(buttonsSpring())} aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> @@ -1328,7 +1336,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => { if (!params.id) { - permission.toggleAutoAcceptDirectory(sdk.directory) + setStore("pendingAutoAccept", (value) => !value) return } permission.toggleAutoAccept(params.id, sdk.directory) @@ -1362,13 +1370,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.mode.shell")}
@@ -1387,13 +1389,7 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} variant="ghost" /> @@ -1411,13 +1407,7 @@ export const PromptInput: Component = (props) => { variant="ghost" size="normal" class="min-w-0 max-w-[320px] text-13-regular group" - style={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + style={{ height: "28px", ...springFade(buttonsSpring()) }} onClick={() => dialog.show(() => )} > @@ -1446,13 +1436,7 @@ export const PromptInput: Component = (props) => { triggerProps={{ variant: "ghost", size: "normal", - style: { - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }, + style: { height: "28px", ...springFade(buttonsSpring()) }, class: "min-w-0 max-w-[320px] text-13-regular group", }} > @@ -1484,13 +1468,7 @@ export const PromptInput: Component = (props) => { onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)} class="capitalize max-w-[160px]" valueClass="truncate text-13-regular" - triggerStyle={{ - height: "28px", - opacity: buttonsSpring(), - transform: `scale(${0.95 + buttonsSpring() * 0.05})`, - filter: `blur(${(1 - buttonsSpring()) * 2}px)`, - "pointer-events": buttonsSpring() > 0.5 ? "auto" : "none", - }} + triggerStyle={{ height: "28px", ...springFade(buttonsSpring()) }} variant="ghost" /> diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index db1b5a5ca1..5a8abc0201 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,8 +1,9 @@ import type { Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" +import { errorMessage } from "@/pages/layout/helpers" import { useNavigate, useParams } from "@solidjs/router" -import type { Accessor } from "solid-js" +import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" @@ -65,14 +66,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const language = useLanguage() const params = useParams() - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } + const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) const abort = async () => { const sessionID = params.id @@ -158,7 +152,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: errorMessage(err), + description: toastError(err), }) return undefined }) @@ -197,7 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), + description: toastError(err), }) return undefined }) @@ -255,7 +249,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { .catch((err) => { showToast({ title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), + description: toastError(err), }) restoreInput() }) @@ -333,9 +327,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { messageID, }) - removeCommentItems(commentItems) - clearInput() - addOptimisticMessage() + batch(() => { + removeCommentItems(commentItems) + clearInput() + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) + } + addOptimisticMessage() + }) const waitForWorktree = async () => { const worktree = WorktreeState.get(sessionDirectory) @@ -412,7 +411,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), + description: toastError(err), }) removeOptimisticMessage() restoreCommentItems(commentItems) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 39eb4b4c0e..0a13e46f69 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -4,8 +4,7 @@ import { useParams } from "@solidjs/router" import { useSync } from "@/context/sync" import { useLayout } from "@/context/layout" import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" -import { same } from "@/utils/same" +import { findLast, same } from "@opencode-ai/util/array" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 42ee4092f6..409c89bdc4 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -288,29 +288,6 @@ export const SettingsGeneral: Component = () => {
- -
- settings.general.setShellToolPartsExpanded(checked)} - /> -
-
- - -
- settings.general.setEditToolPartsExpanded(checked)} - /> -
-
) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 03bd6318da..00d0511a22 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -4,6 +4,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" +import { isEditableTarget } from "@/utils/dom" import { Persist, persisted } from "@/utils/persist" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -177,14 +178,6 @@ export function formatKeybind(config: string): string { return IS_MAC ? parts.join("") : parts.join("+") } -function isEditableTarget(target: EventTarget | null) { - if (!(target instanceof HTMLElement)) return false - if (target.isContentEditable) return true - if (target.closest("[contenteditable='true']")) return true - if (target.closest("input, textarea, select")) return true - return false -} - export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b3a351382f..85945a80b3 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -353,7 +353,6 @@ function createGlobalSync() { .update({ config }) .then(bootstrap) .then(() => { - queue.refresh() setGlobalStore("reload", undefined) queue.refresh() }) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..be1a1769bf 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -146,7 +146,6 @@ const DICT: Record = { } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ - { locale: "en", match: (language) => language.startsWith("en") }, { locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") }, { locale: "zh", match: (language) => language.startsWith("zh") }, { locale: "ko", match: (language) => language.startsWith("ko") }, @@ -218,7 +217,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) const dict = createMemo(() => DICT[locale()]) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 5199e5a26b..130cc3a5ce 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,7 +8,7 @@ import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" import { Persist, persisted, removePersisted } from "@/utils/persist" import { decode64 } from "@/utils/base64" -import { same } from "@/utils/same" +import { same } from "@opencode-ai/util/array" import { createScrollPersistence, type SessionScroll } from "./layout-scroll" import { createPathHelpers } from "./file/path" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 7556113005..2e4cf4fafb 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" -import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" +import { autoRespondsPermission } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => ({ @@ -60,43 +60,4 @@ describe("autoRespondsPermission", () => { expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true) }) - - test("falls back to directory-level auto-accept", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(true) - }) - - test("session-level override takes precedence over directory-level", () => { - const directory = "/tmp/project" - const sessions = [session({ id: "root" })] - const autoAccept = { - [`${base64Encode(directory)}/*`]: true, - [`${base64Encode(directory)}/root`]: false, - } - - expect(autoRespondsPermission(autoAccept, sessions, permission("root"), directory)).toBe(false) - }) -}) - -describe("isDirectoryAutoAccepting", () => { - test("returns true when directory key is set", () => { - const directory = "/tmp/project" - const autoAccept = { [`${base64Encode(directory)}/*`]: true } - expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(true) - }) - - test("returns false when directory key is not set", () => { - expect(isDirectoryAutoAccepting({}, "/tmp/project")).toBe(false) - }) - - test("returns false when directory key is explicitly false", () => { - const directory = "/tmp/project" - const autoAccept = { [`${base64Encode(directory)}/*`]: false } - expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false) - }) }) diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index b206deedff..727ccc9375 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -5,19 +5,9 @@ export function acceptKey(sessionID: string, directory?: string) { return `${base64Encode(directory)}/${sessionID}` } -export function directoryAcceptKey(directory: string) { - return `${base64Encode(directory)}/*` -} - function accepted(autoAccept: Record, sessionID: string, directory?: string) { const key = acceptKey(sessionID, directory) - const directoryKey = directory ? directoryAcceptKey(directory) : undefined - return autoAccept[key] ?? autoAccept[sessionID] ?? (directoryKey ? autoAccept[directoryKey] : undefined) -} - -export function isDirectoryAutoAccepting(autoAccept: Record, directory: string) { - const key = directoryAcceptKey(directory) - return autoAccept[key] ?? false + return autoAccept[key] ?? autoAccept[sessionID] } function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) { diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 672f84f82a..73ee08c9ac 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" @@ -7,12 +7,7 @@ import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "./global-sync" import { useParams } from "@solidjs/router" import { decode64 } from "@/utils/base64" -import { - acceptKey, - directoryAcceptKey, - isDirectoryAutoAccepting, - autoRespondsPermission, -} from "./permission-auto-respond" +import { acceptKey, autoRespondsPermission } from "./permission-auto-respond" type PermissionRespondFn = (input: { sessionID: string @@ -81,25 +76,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }), ) - // When config has permission: "allow", auto-enable directory-level auto-accept - createEffect(() => { - if (!ready()) return - const directory = decode64(params.dir) - if (!directory) return - const [childStore] = globalSync.child(directory) - const perm = childStore.config.permission - if (typeof perm === "string" && perm === "allow") { - const key = directoryAcceptKey(directory) - if (store.autoAccept[key] === undefined) { - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - } - } - }) - const MAX_RESPONDED = 1000 const RESPONDED_TTL_MS = 60 * 60 * 1000 const responded = new Map() @@ -143,10 +119,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return autoRespondsPermission(store.autoAccept, session, { sessionID }, directory) } - function isAutoAcceptingDirectory(directory: string) { - return isDirectoryAutoAccepting(store.autoAccept, directory) - } - function shouldAutoRespond(permission: PermissionRequest, directory?: string) { const session = directory ? globalSync.child(directory, { bootstrap: false })[0].session : [] return autoRespondsPermission(store.autoAccept, session, permission, directory) @@ -170,36 +142,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple }) onCleanup(unsubscribe) - function enableDirectory(directory: string) { - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = true - }), - ) - - globalSDK.client.permission - .list({ directory }) - .then((x) => { - if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - if (!shouldAutoRespond(perm, directory)) continue - respondOnce(perm, directory) - } - }) - .catch(() => undefined) - } - - function disableDirectory(directory: string) { - const key = directoryAcceptKey(directory) - setStore( - produce((draft) => { - draft.autoAccept[key] = false - }), - ) - } - function enable(sessionID: string, directory: string) { const key = acceptKey(sessionID, directory) const version = bumpEnableVersion(sessionID, directory) @@ -243,7 +185,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple return shouldAutoRespond(permission, directory) }, isAutoAccepting, - isAutoAcceptingDirectory, toggleAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) { disable(sessionID, directory) @@ -252,13 +193,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple enable(sessionID, directory) }, - toggleAutoAcceptDirectory(directory: string) { - if (isAutoAcceptingDirectory(directory)) { - disableDirectory(directory) - return - } - enableDirectory(directory) - }, enableAutoAccept(sessionID: string, directory: string) { if (isAutoAccepting(sessionID, directory)) return enable(sessionID, directory) @@ -267,11 +201,6 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple disable(sessionID, directory) }, permissionsEnabled, - isPermissionAllowAll(directory: string) { - const [childStore] = globalSync.child(directory) - const perm = childStore.config.permission - return typeof perm === "string" && perm === "allow" - }, } }, }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index b43469b5c3..d279a7f321 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -23,8 +23,6 @@ export interface Settings { autoSave: boolean releaseNotes: boolean showReasoningSummaries: boolean - shellToolPartsExpanded: boolean - editToolPartsExpanded: boolean } updates: { startup: boolean @@ -46,8 +44,6 @@ const defaultSettings: Settings = { autoSave: true, releaseNotes: true, showReasoningSummaries: false, - shellToolPartsExpanded: true, - editToolPartsExpanded: false, }, updates: { startup: true, @@ -133,20 +129,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setShowReasoningSummaries(value: boolean) { setStore("general", "showReasoningSummaries", value) }, - shellToolPartsExpanded: withFallback( - () => store.general?.shellToolPartsExpanded, - defaultSettings.general.shellToolPartsExpanded, - ), - setShellToolPartsExpanded(value: boolean) { - setStore("general", "shellToolPartsExpanded", value) - }, - editToolPartsExpanded: withFallback( - () => store.general?.editToolPartsExpanded, - defaultSettings.general.editToolPartsExpanded, - ), - setEditToolPartsExpanded(value: boolean) { - setStore("general", "editToolPartsExpanded", value) - }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 16f2fbf492..e3183c4846 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -541,12 +541,6 @@ export const dict = { "settings.general.row.theme.description": "تخصيص سمة OpenCode.", "settings.general.row.font.title": "الخط", "settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية", - "settings.general.row.shellToolPartsExpanded.title": "توسيع أجزاء أداة shell", - "settings.general.row.shellToolPartsExpanded.description": - "إظهار أجزاء أداة shell موسعة بشكل افتراضي في الشريط الزمني", - "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", - "settings.general.row.editToolPartsExpanded.description": - "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 26cf433e0e..84d1f9d10f 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -547,12 +547,6 @@ export const dict = { "settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.", "settings.general.row.font.title": "Fonte", "settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código", - "settings.general.row.shellToolPartsExpanded.title": "Expandir partes da ferramenta shell", - "settings.general.row.shellToolPartsExpanded.description": - "Mostrar partes da ferramenta shell expandidas por padrão na linha do tempo", - "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", - "settings.general.row.editToolPartsExpanded.description": - "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 6c8198bd71..58c3ba6067 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -613,12 +613,6 @@ export const dict = { "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda", - "settings.general.row.shellToolPartsExpanded.title": "Proširi dijelove shell alata", - "settings.general.row.shellToolPartsExpanded.description": - "Prikaži dijelove shell alata podrazumijevano proširene na vremenskoj traci", - "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", - "settings.general.row.editToolPartsExpanded.description": - "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", "settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 11da681760..53bbb09b84 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -608,11 +608,6 @@ export const dict = { "settings.general.row.font.title": "Skrifttype", "settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke", - "settings.general.row.shellToolPartsExpanded.title": "Udvid shell-værktøjsdele", - "settings.general.row.shellToolPartsExpanded.description": "Vis shell-værktøjsdele udvidet som standard i tidslinjen", - "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", - "settings.general.row.editToolPartsExpanded.description": - "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", "settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 51b9ec3531..753b95c63b 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -556,12 +556,6 @@ export const dict = { "settings.general.row.theme.description": "Das Thema von OpenCode anpassen.", "settings.general.row.font.title": "Schriftart", "settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen", - "settings.general.row.shellToolPartsExpanded.title": "Shell-Tool-Abschnitte ausklappen", - "settings.general.row.shellToolPartsExpanded.description": - "Shell-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", - "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", - "settings.general.row.editToolPartsExpanded.description": - "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", "settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739d..e709e23a31 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -636,13 +636,6 @@ export const dict = { "settings.general.row.font.description": "Customise the mono font used in code blocks", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", - "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", - "settings.general.row.shellToolPartsExpanded.description": - "Show shell tool parts expanded by default in the timeline", - "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", - "settings.general.row.editToolPartsExpanded.description": - "Show edit, write, and patch tool parts expanded by default in the timeline", - "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 2665a80850..cdfc17289a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -616,12 +616,6 @@ export const dict = { "settings.general.row.font.title": "Fuente", "settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código", - "settings.general.row.shellToolPartsExpanded.title": "Expandir partes de la herramienta shell", - "settings.general.row.shellToolPartsExpanded.description": - "Mostrar las partes de la herramienta shell expandidas por defecto en la línea de tiempo", - "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", - "settings.general.row.editToolPartsExpanded.description": - "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 1e67db1933..b606c33017 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -553,12 +553,6 @@ export const dict = { "settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.", "settings.general.row.font.title": "Police", "settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code", - "settings.general.row.shellToolPartsExpanded.title": "Développer les parties de l'outil shell", - "settings.general.row.shellToolPartsExpanded.description": - "Afficher les parties de l'outil shell développées par défaut dans la chronologie", - "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", - "settings.general.row.editToolPartsExpanded.description": - "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", "settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index ecd38d3324..d751318ce5 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -545,12 +545,6 @@ export const dict = { "settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。", "settings.general.row.font.title": "フォント", "settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします", - "settings.general.row.shellToolPartsExpanded.title": "shell ツールパーツを展開", - "settings.general.row.shellToolPartsExpanded.description": - "タイムラインで shell ツールパーツをデフォルトで展開して表示します", - "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", - "settings.general.row.editToolPartsExpanded.description": - "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", "settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 8f54b8abdc..8ba55b6308 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -546,12 +546,6 @@ export const dict = { "settings.general.row.theme.description": "OpenCode 테마 사용자 지정", "settings.general.row.font.title": "글꼴", "settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정", - "settings.general.row.shellToolPartsExpanded.title": "shell 도구 파트 펼치기", - "settings.general.row.shellToolPartsExpanded.description": - "타임라인에서 기본적으로 shell 도구 파트를 펼친 상태로 표시합니다", - "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기", - "settings.general.row.editToolPartsExpanded.description": - "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다", "settings.general.row.wayland.title": "네이티브 Wayland 사용", "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0c94046eb0..9005f66a40 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -616,11 +616,6 @@ export const dict = { "settings.general.row.font.title": "Skrift", "settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker", - "settings.general.row.shellToolPartsExpanded.title": "Utvid shell-verktøydeler", - "settings.general.row.shellToolPartsExpanded.description": "Vis shell-verktøydeler utvidet som standard i tidslinjen", - "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler", - "settings.general.row.editToolPartsExpanded.description": - "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen", "settings.general.row.wayland.title": "Bruk innebygd Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 59c0513be6..66ac90d4e1 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -546,12 +546,6 @@ export const dict = { "settings.general.row.theme.description": "Dostosuj motyw OpenCode.", "settings.general.row.font.title": "Czcionka", "settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu", - "settings.general.row.shellToolPartsExpanded.title": "Rozwijaj elementy narzędzia shell", - "settings.general.row.shellToolPartsExpanded.description": - "Domyślnie pokazuj rozwinięte elementy narzędzia shell na osi czasu", - "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", - "settings.general.row.editToolPartsExpanded.description": - "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", "settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 2071eaae7b..23317be70b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -614,12 +614,6 @@ export const dict = { "settings.general.row.font.title": "Шрифт", "settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода", - "settings.general.row.shellToolPartsExpanded.title": "Разворачивать элементы инструмента shell", - "settings.general.row.shellToolPartsExpanded.description": - "Показывать элементы инструмента shell в ленте развернутыми по умолчанию", - "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", - "settings.general.row.editToolPartsExpanded.description": - "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", "settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9871555536..818c35c3c2 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -608,11 +608,6 @@ export const dict = { "settings.general.row.font.title": "ฟอนต์", "settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด", - "settings.general.row.shellToolPartsExpanded.title": "ขยายส่วนเครื่องมือ shell", - "settings.general.row.shellToolPartsExpanded.description": "แสดงส่วนเครื่องมือ shell แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", - "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", - "settings.general.row.editToolPartsExpanded.description": - "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 701ee09192..13816bf7f5 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -623,13 +623,6 @@ export const dict = { "settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin", "settings.general.row.reasoningSummaries.title": "Akıl yürütme özetlerini göster", "settings.general.row.reasoningSummaries.description": "Zaman çizelgesinde model akıl yürütme özetlerini görüntüle", - "settings.general.row.shellToolPartsExpanded.title": "Kabuk araç bileşenlerini genişlet", - "settings.general.row.shellToolPartsExpanded.description": - "Zaman çizelgesinde kabuk araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.editToolPartsExpanded.title": "Düzenleme araç bileşenlerini genişlet", - "settings.general.row.editToolPartsExpanded.description": - "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", - "settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.description": "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index e72d4c0e3b..217d444a6f 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -607,10 +607,6 @@ export const dict = { "settings.general.row.theme.description": "自定义 OpenCode 的主题。", "settings.general.row.font.title": "字体", "settings.general.row.font.description": "自定义代码块使用的等宽字体", - "settings.general.row.shellToolPartsExpanded.title": "展开 shell 工具部分", - "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", - "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", - "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。", "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 70421dfe10..84fe42efe5 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -603,10 +603,6 @@ export const dict = { "settings.general.row.font.title": "字型", "settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型", - "settings.general.row.shellToolPartsExpanded.title": "展開 shell 工具區塊", - "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", - "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", - "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index f6165461b1..2019ca4e5a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -10,8 +10,9 @@ import { ParentProps, Show, untrack, + type JSX, } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" +import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" @@ -19,8 +20,9 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" @@ -57,6 +59,7 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { + childMapByParent, displayName, effectiveWorkspaceOrder, errorMessage, @@ -1843,7 +1846,7 @@ export default function Layout(props: ParentProps) { }} style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }} > - + {(p) => ( <>
@@ -1852,7 +1855,7 @@ export default function Layout(props: ParentProps) { renameProject(p(), next)} + onSave={(next) => renameProject(p, next)} class="text-14-medium text-text-strong truncate" displayClass="text-14-medium text-text-strong truncate" stopPropagation @@ -1861,7 +1864,7 @@ export default function Layout(props: ParentProps) { - {p().worktree.replace(homedir(), "~")} + {p.worktree.replace(homedir(), "~")}
@@ -1880,7 +1883,7 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p().worktree)} + data-project={base64Encode(p.worktree)} class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" classList={{ "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, @@ -1889,24 +1892,24 @@ export default function Layout(props: ParentProps) { /> - showEditProjectDialog(p())}> + showEditProjectDialog(p)}> {language.t("common.edit")} toggleProjectWorkspaces(p())} + data-project={base64Encode(p.worktree)} + disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()} + onSelect={() => toggleProjectWorkspaces(p)} > - {layout.sidebar.workspaces(p().worktree)() + {layout.sidebar.workspaces(p.worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -1917,8 +1920,8 @@ export default function Layout(props: ParentProps) { closeProject(p().worktree)} + data-project={base64Encode(p.worktree)} + onSelect={() => closeProject(p.worktree)} > {language.t("common.close")} @@ -1934,19 +1937,25 @@ export default function Layout(props: ParentProps) { fallback={ <>
- + +
@@ -1956,9 +1965,15 @@ export default function Layout(props: ParentProps) { > <>
- + + +
@@ -2093,9 +2108,11 @@ export default function Layout(props: ParentProps) { />
-
- -
+ {(worktree) => ( +
+ +
+ )}
{ - const delta = el.scrollHeight - beforeHeight - if (!delta) return - el.scrollTop = beforeTop + delta - }) + // SolidJS updates the DOM synchronously. Force reflow so the browser + // processes the new layout, then restore scrollTop before paint. + // With column-reverse + overflow-anchor:none the same scrollTop value + // keeps the same distance from the bottom — no delta math needed. + void el.scrollHeight + el.scrollTop = beforeTop } const backfillTurns = () => { @@ -207,7 +208,8 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) { if (!input.userScrolled()) return const el = input.scroller() if (!el) return - if (el.scrollTop >= turnScrollThreshold) return + // With column-reverse, distance from top = scrollHeight - clientHeight + scrollTop + if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return const start = turnStart() if (start > 0) { @@ -285,7 +287,6 @@ export default function Page() { bottom: true, }, }) - const composer = createSessionComposerState() const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) @@ -430,20 +431,8 @@ export default function Page() { mobileTab: "session" as "session" | "changes", changes: "session" as "session" | "turn", newSessionWorktree: "main", - deferRender: false, }) - createComputed((prev) => { - const key = sessionKey() - if (key !== prev) { - setStore("deferRender", true) - requestAnimationFrame(() => { - setTimeout(() => setStore("deferRender", false), 0) - }) - } - return key - }, sessionKey()) - const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? []) const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs())) @@ -454,11 +443,6 @@ export default function Page() { return "main" }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - const found = visibleUserMessages()?.find((m) => m.id === store.messageId) - return found ?? lastUserMessage() - }) const setActiveMessage = (message: UserMessage | undefined) => { setStore("messageId", message?.id) } @@ -620,11 +604,6 @@ export default function Page() { saveLabel: language.t("common.save"), })) - const isEditableTarget = (target: EventTarget | null | undefined) => { - if (!(target instanceof HTMLElement)) return false - return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable - } - const deepActiveElement = () => { let current: Element | null = document.activeElement while (current instanceof HTMLElement && current.shadowRoot?.activeElement) { @@ -755,12 +734,35 @@ export default function Page() { loadingClass: string emptyClass: string }) => ( - - - + + + setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> + + + {language.t("session.review.loadingChanges")}
} + > - - - {language.t("session.review.loadingChanges")}
} - > - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> - - - - - -
{language.t(reviewEmptyKey())}
- - ) - } - diffs={reviewDiffs} - view={view} - diffStyle={input.diffStyle} - onDiffStyleChange={input.onDiffStyleChange} - onScrollRef={(el) => setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> -
- - + + + + + +
{language.t(reviewEmptyKey())}
+ + ) + } + diffs={reviewDiffs} + view={view} + diffStyle={input.diffStyle} + onDiffStyleChange={input.onDiffStyleChange} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} + onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} + onLineCommentUpdate={updateCommentInContext} + onLineCommentDelete={removeCommentFromContext} + lineCommentActions={reviewCommentActions()} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={openReviewFile} + classes={input.classes} + /> +
+ ) const reviewPanel = () => ( @@ -1045,7 +1022,10 @@ export default function Page() { const updateScrollState = (el: HTMLDivElement) => { const max = el.scrollHeight - el.clientHeight const overflow = max > 1 - const bottom = !overflow || el.scrollTop >= max - 2 + // If auto-scroll is tracking the bottom, always report bottom: true + // to prevent the scroll-down arrow from flashing during height animations + // With column-reverse, scrollTop=0 is at the bottom + const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled() if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return setUi("scroll", { overflow, bottom }) @@ -1068,7 +1048,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.forceScrollToBottom() + autoScroll.smoothScrollToBottom() clearMessageHash() const el = scroller @@ -1136,9 +1116,8 @@ export default function Page() { const el = scroller const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false + // With column-reverse, near bottom = scrollTop near 0 + const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false dockHeight = next @@ -1204,50 +1183,49 @@ export default function Page() {
- - { - content = el - autoScroll.contentRef(el) + { + content = el + autoScroll.contentRef(el) - const root = scroller - if (root) scheduleScrollState(root) - }} - turnStart={historyWindow.turnStart()} - historyMore={historyMore()} - historyLoading={historyLoading()} - onLoadEarlier={() => { - void historyWindow.loadAndReveal() - }} - renderedUserMessages={historyWindow.renderedUserMessages()} - anchor={anchor} - onRegisterMessage={scrollSpy.register} - onUnregisterMessage={scrollSpy.unregister} - /> - + const root = scroller + if (root) scheduleScrollState(root) + }} + turnStart={historyWindow.turnStart()} + historyMore={historyMore()} + historyLoading={historyLoading()} + onLoadEarlier={() => { + void historyWindow.loadAndReveal() + }} + renderedUserMessages={historyWindow.renderedUserMessages()} + anchor={anchor} + onRegisterMessage={scrollSpy.register} + onUnregisterMessage={scrollSpy.unregister} + /> { inputRef = el diff --git a/packages/app/src/pages/session/composer/index.ts b/packages/app/src/pages/session/composer/index.ts index e244a15363..066a586a3b 100644 --- a/packages/app/src/pages/session/composer/index.ts +++ b/packages/app/src/pages/session/composer/index.ts @@ -1,3 +1,29 @@ +/** + * Composer Island migration tracker + * + * Goal + * - Replace the split composer stack (PromptInput + question/permission/todo docks) + * with a single morphing ComposerIsland + app runtime adapter. + * + * Current status + * - [x] Storybook prototype with morphing surfaces exists (`packages/ui/src/components/composer-island.tsx`). + * - [ ] App still renders the existing production stack (`session-composer-region` + `prompt-input`). + * + * Feature parity checklist + * - [ ] Submit pipeline parity (session/worktree/create, optimistic user message, abort, restore on error). + * - [x] Runtime adapter API boundary in island (`runtime.submit`, `runtime.abort`, lookup, permission/question handlers). + * - [x] Shell mode wiring parity (single mode source for tray + editor). + * - [x] Cursor scroll-into-view behavior in island editor. + * - [x] Attachment parity in island UI: image + PDF support and file picker wiring. + * - [x] Drag/drop parity in island UI: attachment drop + `file:` text drop into @mention. + * - [x] Keyboard parity in island UI: mod+u, ctrl+g, ctrl+n/ctrl+p, popover navigation. + * - [x] Auto-accept + stop/send state parity in island UI. + * - [x] Async @mention/slash sourcing parity in island via runtime search hooks. + * - [ ] Context item parity (comment chips/open behavior is in progress; app comment wiring still pending). + * - [ ] Question flow parity (island submit/reject hooks + sending locks done; app SDK/cache wiring pending). + * - [ ] Permission flow parity (island responding lock done; app tool-description wiring pending). + * - [x] Todo parity in island UI (in-progress pulse + auto-scroll active item). + */ 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 index 93ea3d465c..6f9b16f336 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,7 +1,7 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" -import { createStore } from "solid-js/store" +import { Show, createMemo, createSignal, createEffect } from "solid-js" import { useParams } from "@solidjs/router" import { useSpring } from "@opencode-ai/ui/motion-spring" +import { useElementHeight } from "@opencode-ai/ui/hooks" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" @@ -9,11 +9,12 @@ 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" +import { SessionTodoDock, COLLAPSED_HEIGHT } from "@/pages/session/composer/session-todo-dock" + +const DOCK_SPRING = { visualDuration: 0.3, bounce: 0 } export function SessionComposerRegion(props: { state: SessionComposerState - ready: boolean centered: boolean inputRef: (el: HTMLDivElement) => void newSessionWorktree: string @@ -21,23 +22,6 @@ export function SessionComposerRegion(props: { onSubmit: () => void onResponseSubmit: () => void setPromptDockRef: (el: HTMLDivElement) => void - visualDuration?: number - bounce?: number - dockOpenVisualDuration?: number - dockOpenBounce?: number - dockCloseVisualDuration?: number - dockCloseBounce?: number - drawerExpandVisualDuration?: number - drawerExpandBounce?: number - drawerCollapseVisualDuration?: number - drawerCollapseBounce?: number - subtitleDuration?: number - subtitleTravel?: number - subtitleEdge?: number - countDuration?: number - countMask?: number - countMaskHeight?: number - countWidthDuration?: number }) { const params = useParams() const prompt = usePrompt() @@ -63,73 +47,15 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const [gate, setGate] = createStore({ - ready: false, - }) - let timer: number | undefined - let frame: number | undefined - - const clear = () => { - if (timer !== undefined) { - window.clearTimeout(timer) - timer = undefined - } - if (frame !== undefined) { - cancelAnimationFrame(frame) - frame = undefined - } - } - - createEffect(() => { - sessionKey() - const ready = props.ready - const delay = 140 - - clear() - setGate("ready", false) - if (!ready) return - - frame = requestAnimationFrame(() => { - frame = undefined - timer = window.setTimeout(() => { - setGate("ready", true) - timer = undefined - }, delay) - }) - }) - - onCleanup(clear) - - const open = createMemo(() => gate.ready && props.state.dock() && !props.state.closing()) - const config = createMemo(() => - open() - ? { - visualDuration: props.dockOpenVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockOpenBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.dockCloseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.dockCloseBounce ?? props.bounce ?? 0, - }, + const open = createMemo(() => props.state.dock() && !props.state.closing()) + const progress = useSpring( + () => (open() ? 1 : 0), + DOCK_SPRING, ) - const progress = useSpring(() => (open() ? 1 : 0), config) - const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const [height, setHeight] = createSignal(320) - const dock = createMemo(() => (gate.ready && props.state.dock()) || value() > 0.001) - const full = createMemo(() => Math.max(78, height())) + const dock = createMemo(() => props.state.dock() || progress() > 0.001) const [contentRef, setContentRef] = createSignal() - - createEffect(() => { - const el = contentRef() - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -191,20 +117,7 @@ export function SessionComposerRegion(props: { title={language.t("session.todo.title")} collapseLabel={language.t("session.todo.collapse")} expandLabel={language.t("session.todo.expand")} - dockProgress={value()} - visualDuration={props.visualDuration} - bounce={props.bounce} - expandVisualDuration={props.drawerExpandVisualDuration} - expandBounce={props.drawerExpandBounce} - collapseVisualDuration={props.drawerCollapseVisualDuration} - collapseBounce={props.drawerCollapseBounce} - subtitleDuration={props.subtitleDuration} - subtitleTravel={props.subtitleTravel} - subtitleEdge={props.subtitleEdge} - countDuration={props.countDuration} - countMask={props.countMask} - countMaskHeight={props.countMaskHeight} - countWidthDuration={props.countWidthDuration} + dockProgress={progress()} />
@@ -214,7 +127,7 @@ export function SessionComposerRegion(props: { "relative z-10": true, }} style={{ - "margin-top": `${-36 * value()}px`, + "margin-top": `${-36 * progress()}px`, }} > number) }) { +export function createSessionComposerState( + options?: { + closeMs?: number | (() => number) + }, +) { const params = useParams() const sdk = useSDK() const sync = useSync() diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index da2b8c8da1..ab6cca83e1 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -6,9 +6,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { useSpring } from "@opencode-ai/ui/motion-spring" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" +import { useElementHeight } from "@opencode-ai/ui/hooks" import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" +const COLLAPSE_SPRING = { visualDuration: 0.3, bounce: 0 } +export const COLLAPSED_HEIGHT = 78 +const SUBTITLE = { duration: 600, travel: 25, edge: 17 } +const COUNT = { duration: 600, mask: 18, maskHeight: 0, widthDuration: 560 } + function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined return ( @@ -40,19 +46,6 @@ export function SessionTodoDock(props: { collapseLabel: string expandLabel: string dockProgress?: number - visualDuration?: number - bounce?: number - expandVisualDuration?: number - expandBounce?: number - collapseVisualDuration?: number - collapseBounce?: number - subtitleDuration?: number - subtitleTravel?: number - subtitleEdge?: number - countDuration?: number - countMask?: number - countMaskHeight?: number - countWidthDuration?: number }) { const [store, setStore] = createStore({ collapsed: false, @@ -73,39 +66,12 @@ export function SessionTodoDock(props: { ) const preview = createMemo(() => active()?.content ?? "") - const config = createMemo(() => - store.collapsed - ? { - visualDuration: props.collapseVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.collapseBounce ?? props.bounce ?? 0, - } - : { - visualDuration: props.expandVisualDuration ?? props.visualDuration ?? 0.3, - bounce: props.expandBounce ?? props.bounce ?? 0, - }, - ) - const collapse = useSpring(() => (store.collapsed ? 1 : 0), config) - const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) - const shut = createMemo(() => 1 - dock()) - const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) - const hide = createMemo(() => Math.max(value(), shut())) - const off = createMemo(() => hide() > 0.98) - const turn = createMemo(() => Math.max(0, Math.min(1, value()))) - const [height, setHeight] = createSignal(320) - const full = createMemo(() => Math.max(78, height())) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), COLLAPSE_SPRING) + const shut = createMemo(() => 1 - (props.dockProgress ?? 1)) + const hide = createMemo(() => Math.max(collapse(), shut())) let contentRef: HTMLDivElement | undefined - - createEffect(() => { - const el = contentRef - if (!el) return - const update = () => { - setHeight(el.getBoundingClientRect().height) - } - update() - const observer = new ResizeObserver(update) - observer.observe(el) - onCleanup(() => observer.disconnect()) - }) + const height = useElementHeight(() => contentRef, 320) + const full = createMemo(() => Math.max(COLLAPSED_HEIGHT, height())) return (
@@ -133,12 +99,12 @@ export function SessionTodoDock(props: { class="text-14-regular text-text-strong cursor-default inline-flex items-baseline shrink-0 whitespace-nowrap overflow-visible" aria-label={label()} style={{ - "--tool-motion-odometer-ms": `${props.countDuration ?? 600}ms`, - "--tool-motion-mask": `${props.countMask ?? 18}%`, - "--tool-motion-mask-height": `${props.countMaskHeight ?? 0}px`, - "--tool-motion-spring-ms": `${props.countWidthDuration ?? 560}ms`, - opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, - filter: `blur(${Math.max(0, Math.min(1, shut())) * 2}px)`, + "--tool-motion-odometer-ms": `${COUNT.duration}ms`, + "--tool-motion-mask": `${COUNT.mask}%`, + "--tool-motion-mask-height": `${COUNT.maskHeight}px`, + "--tool-motion-spring-ms": `${COUNT.widthDuration}ms`, + opacity: `${1 - shut()}`, + filter: shut() > 0.01 ? `blur(${shut() * 2}px)` : "none", }} > @@ -157,9 +123,9 @@ export function SessionTodoDock(props: { { event.preventDefault() event.stopPropagation() @@ -189,14 +155,15 @@ export function SessionTodoDock(props: {
0.1, }} style={{ - visibility: off() ? "hidden" : "visible", - opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, - filter: `blur(${Math.max(0, Math.min(1, hide())) * 2}px)`, + opacity: `${1 - hide()}`, + filter: hide() > 0.01 ? `blur(${hide() * 2}px)` : "none", + visibility: hide() > 0.98 ? "hidden" : "visible", }} > @@ -282,7 +249,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { "--checkbox-align": "flex-start", "--checkbox-offset": "1px", transition: "opacity 220ms var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1))", - opacity: todo().status === "pending" ? "0.94" : "1", + opacity: todo().status === "pending" ? "0.5" : "1", }} > diff --git a/packages/app/src/pages/session/message-gesture.test.ts b/packages/app/src/pages/session/message-gesture.test.ts index b2af4bb834..62758b1d89 100644 --- a/packages/app/src/pages/session/message-gesture.test.ts +++ b/packages/app/src/pages/session/message-gesture.test.ts @@ -49,11 +49,11 @@ describe("shouldMarkBoundaryGesture", () => { ).toBe(true) }) - test("does not mark when nested scroller can consume movement", () => { + test("does not mark when scroller can consume movement", () => { expect( shouldMarkBoundaryGesture({ delta: 20, - scrollTop: 200, + scrollTop: 300, scrollHeight: 1000, clientHeight: 400, }), diff --git a/packages/app/src/pages/session/message-gesture.ts b/packages/app/src/pages/session/message-gesture.ts index 731cb1bdeb..cd29b5392d 100644 --- a/packages/app/src/pages/session/message-gesture.ts +++ b/packages/app/src/pages/session/message-gesture.ts @@ -14,8 +14,8 @@ export const shouldMarkBoundaryGesture = (input: { if (max <= 1) return true if (!input.delta) return false - if (input.delta < 0) return input.scrollTop + input.delta <= 0 - - const remaining = max - input.scrollTop - return input.delta > remaining + const top = Math.max(0, Math.min(max, input.scrollTop)) + if (input.delta < 0) return -input.delta > top + const bottom = max - top + return input.delta > bottom } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index ce6a01378c..784d170737 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,27 +1,31 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate, useParams } from "@solidjs/router" +import { + For, + Index, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + Show, + startTransition, + type JSX, +} from "solid-js" +import { createStore } from "solid-js/store" +import { useParams } from "@solidjs/router" import { Button } from "@opencode-ai/ui/button" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Dialog } from "@opencode-ai/ui/dialog" -import { InlineInput } from "@opencode-ai/ui/inline-input" import { SessionTurn } from "@opencode-ai/ui/session-turn" import { ScrollView } from "@opencode-ai/ui/scroll-view" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2" -import { showToast } from "@opencode-ai/ui/toast" import { Binary } from "@opencode-ai/util/binary" import { getFilename } from "@opencode-ai/util/path" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" -import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import { SessionTimelineHeader } from "@/pages/session/session-timeline-header" type MessageComment = { path: string @@ -33,7 +37,9 @@ type MessageComment = { } const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } + +const isDefaultSessionTitle = (title?: string) => + !!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title) const messageComments = (parts: Part[]): MessageComment[] => parts.flatMap((part) => { @@ -110,6 +116,8 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) + const [readySession, setReadySession] = createSignal("") + let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -134,23 +142,46 @@ function createTimelineStaging(input: TimelineStageInput) { cancelAnimationFrame(frame) frame = undefined } + const scheduleReady = (sessionKey: string) => { + if (input.sessionKey() !== sessionKey) return + if (readySession() === sessionKey) return + setReadySession(sessionKey) + } createEffect( on( () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, ([sessionKey, isWindowed, total]) => { + const switched = active !== sessionKey + if (switched) { + active = sessionKey + setReadySession("") + } + + const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey + const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey + + if (staging && !switched && shouldStage && frame !== undefined) return + cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey + + if (shouldStage) setReadySession("") if (!shouldStage) { - setState({ activeSession: "", count: total }) + setState({ + activeSession: "", + completedSession: isWindowed ? sessionKey : state.completedSession, + count: total, + }) + if (total <= 0) { + setReadySession("") + return + } + if (readySession() !== sessionKey) scheduleReady(sessionKey) return } let count = Math.min(total, input.config.init) + if (staging) count = Math.min(total, Math.max(count, state.count)) setState({ activeSession: sessionKey, count }) const step = () => { @@ -160,10 +191,11 @@ function createTimelineStaging(input: TimelineStageInput) { } const currentTotal = input.messages().length count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) + startTransition(() => setState("count", count)) if (count >= currentTotal) { setState({ completedSession: sessionKey, activeSession: "" }) frame = undefined + scheduleReady(sessionKey) return } frame = requestAnimationFrame(step) @@ -177,9 +209,12 @@ function createTimelineStaging(input: TimelineStageInput) { const key = input.sessionKey() return state.activeSession === key && state.completedSession !== key }) + const ready = createMemo(() => readySession() === input.sessionKey()) - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } + onCleanup(() => { + cancel() + }) + return { messages: stagedUserMessages, isStaging, ready } } export function MessageTimeline(props: { @@ -196,6 +231,7 @@ export function MessageTimeline(props: { onScrollSpyScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void + onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -210,14 +246,19 @@ export function MessageTimeline(props: { let touchGesture: number | undefined const params = useParams() - const navigate = useNavigate() - const sdk = useSDK() const sync = useSync() const settings = useSettings() - const dialog = useDialog() const language = useLanguage() - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) + const trigger = (target: EventTarget | null) => { + const next = + target instanceof Element + ? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]') + : undefined + if (!(next instanceof HTMLElement)) return + return next + } + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionID = createMemo(() => params.id) const sessionMessages = createMemo(() => { @@ -230,28 +271,20 @@ export function MessageTimeline(props: { (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number", ), ) - const sessionStatus = createMemo(() => { - const id = sessionID() - if (!id) return idle - return sync.data.session_status[id] ?? idle - }) + const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle") const activeMessageID = createMemo(() => { - const parentID = pending()?.parentID - if (parentID) { - const messages = sessionMessages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message && message.role === "user") return message.id + const messages = sessionMessages() + const message = pending() + if (message?.parentID) { + const result = Binary.search(messages, message.parentID, (item) => item.id) + const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID) + if (parent?.role === "user") return parent.id } - const status = sessionStatus() - if (status.type !== "idle") { - const messages = sessionMessages() - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].role === "user") return messages[i].id - } + if (sessionStatus() === "idle") return undefined + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === "user") return messages[i].id } - return undefined }) const info = createMemo(() => { @@ -259,9 +292,19 @@ export function MessageTimeline(props: { if (!id) return return sync.session.get(id) }) - const titleValue = createMemo(() => info()?.title) + const titleValue = createMemo(() => { + const title = info()?.title + if (!title) return + if (isDefaultSessionTitle(title)) return language.t("command.session.new") + return title + }) + const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title)) + const headerTitle = createMemo( + () => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined), + ) + const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0)) const parentID = createMemo(() => info()?.parentID) - const showHeader = createMemo(() => !!(titleValue() || parentID())) + const showHeader = createMemo(() => !!(headerTitle() || parentID())) const stageCfg = { init: 1, batch: 3 } const staging = createTimelineStaging({ sessionKey, @@ -269,212 +312,7 @@ export function MessageTimeline(props: { messages: () => props.renderedUserMessages, config: stageCfg, }) - - const [title, setTitle] = createStore({ - draft: "", - editing: false, - saving: false, - menuOpen: false, - pendingRename: false, - }) - let titleRef: HTMLInputElement | undefined - - const errorMessage = (err: unknown) => { - if (err && typeof err === "object" && "data" in err) { - const data = (err as { data?: { message?: string } }).data - if (data?.message) return data.message - } - if (err instanceof Error) return err.message - return language.t("common.requestFailed") - } - - createEffect( - on( - sessionKey, - () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!sessionID()) return - setTitle({ editing: true, draft: titleValue() ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (title.saving) return - setTitle({ editing: false, saving: false }) - } - - const saveTitleEditor = async () => { - const id = sessionID() - if (!id) return - if (title.saving) return - - const next = title.draft.trim() - if (!next || next === (titleValue() ?? "")) { - setTitle({ editing: false, saving: false }) - return - } - - setTitle("saving", true) - await sdk.client.session - .update({ sessionID: id, title: next }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === id) - if (index !== -1) draft.session[index].title = next - }), - ) - setTitle({ editing: false, saving: false }) - }) - .catch((err) => { - setTitle("saving", false) - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { - if (params.id !== sessionID) return - if (parentID) { - navigate(`/${params.dir}/session/${parentID}`) - return - } - if (nextSessionID) { - navigate(`/${params.dir}/session/${nextSessionID}`) - return - } - navigate(`/${params.dir}/session`) - } - - const archiveSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return - - const sessions = sync.data.session ?? [] - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - await sdk.client.session - .update({ sessionID, time: { archived: Date.now() } }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === sessionID) - if (index !== -1) draft.session.splice(index, 1) - }), - ) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - } - - const deleteSession = async (sessionID: string) => { - const session = sync.session.get(sessionID) - if (!session) return false - - const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === sessionID) - const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) - - const result = await sdk.client.session - .delete({ sessionID }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return false - - sync.set( - produce((draft) => { - const removed = new Set([sessionID]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(props: { sessionID: string }) { - const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) - const handleDelete = async () => { - await deleteSession(props.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } + const rendered = createMemo(() => staging.messages().map((message) => message.id)) return (
+ { @@ -532,9 +380,18 @@ export function MessageTimeline(props: { touchGesture = undefined }} onPointerDown={(e) => { + const next = trigger(e.target) + if (next) props.onPreserveScrollAnchor(next) + if (e.target !== e.currentTarget) return props.onMarkScrollGesture(e.currentTarget) }} + onKeyDown={(e) => { + if (e.key !== "Enter" && e.key !== " ") return + const next = trigger(e.target) + if (!next) return + props.onPreserveScrollAnchor(next) + }} onScroll={(e) => { props.onScheduleScrollState(e.currentTarget) props.onTurnBackfillScroll() @@ -543,131 +400,21 @@ export function MessageTimeline(props: { props.onMarkScrollGesture(e.currentTarget) if (props.isDesktop) props.onScrollSpyScroll() }} - onClick={props.onAutoScrollInteraction} + onClick={(e) => { + props.onAutoScrollInteraction(e) + }} class="relative min-w-0 w-full h-full" style={{ - "--session-title-height": showHeader() ? "40px" : "0px", + "--session-title-height": showHeader() ? "72px" : "0px", "--sticky-accordion-top": showHeader() ? "48px" : "0px", }} > -
- -
-
-
- - - - - - {titleValue()} - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )} - > - {language.t("common.delete")} - - - - -
- )} -
-
-
-
- +
{(messageID) => { + // Capture at creation time: animate only messages added after the + // timeline finishes its initial backfill staging, plus the first + // turn while a brand new session is still using its default title. + const isNew = + staging.ready() || + (defaultTitle() && + sessionStatus() !== "idle" && + props.renderedUserMessages.length === 1 && + messageID === props.renderedUserMessages[0]?.id) const active = createMemo(() => activeMessageID() === messageID) const queued = createMemo(() => { if (active()) return false @@ -700,7 +456,10 @@ export function MessageTimeline(props: { return false }) const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), + equals: (a, b) => { + if (a.length !== b.length) return false + return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment) + }, }) const commentCount = createMemo(() => comments().length) return ( @@ -757,10 +516,10 @@ export function MessageTimeline(props: { messageID={messageID} active={active()} queued={queued()} - status={active() ? sessionStatus() : undefined} + animate={isNew || active()} showReasoningSummaries={settings.general.showReasoningSummaries()} - shellToolDefaultOpen={settings.general.shellToolPartsExpanded()} - editToolDefaultOpen={settings.general.editToolPartsExpanded()} + shellToolDefaultOpen={false} + editToolDefaultOpen={false} classes={{ root: "min-w-0 w-full relative", content: "flex flex-col justify-between !overflow-visible", diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index ad802d15d1..55c1607a09 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -331,7 +331,9 @@ export function SessionSidePanel(props: { const path = createMemo(() => file.pathFromTab(tab)) return (
- {(p) => } + + {(p) => } +
) }} diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx new file mode 100644 index 0000000000..fcddb38a47 --- /dev/null +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -0,0 +1,522 @@ +import { createEffect, createMemo, on, onCleanup, Show } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { useNavigate, useParams } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Dialog } from "@opencode-ai/ui/dialog" +import { prefersReducedMotion } from "@opencode-ai/ui/hooks" +import { InlineInput } from "@opencode-ai/ui/inline-input" +import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" +import { showToast } from "@opencode-ai/ui/toast" +import { errorMessage } from "@/pages/layout/helpers" +import { SessionContextUsage } from "@/components/session-context-usage" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" + +export function SessionTimelineHeader(props: { + centered: boolean + showHeader: () => boolean + sessionKey: () => string + sessionID: () => string | undefined + parentID: () => string | undefined + titleValue: () => string | undefined + headerTitle: () => string | undefined + placeholderTitle: () => boolean +}) { + const navigate = useNavigate() + const params = useParams() + const sdk = useSDK() + const sync = useSync() + const dialog = useDialog() + const language = useLanguage() + const reduce = prefersReducedMotion + + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + const [headerText, setHeaderText] = createStore({ + session: props.sessionKey(), + value: props.headerTitle(), + prev: undefined as string | undefined, + muted: props.placeholderTitle(), + prevMuted: false, + }) + let headerAnim: AnimationPlaybackControls | undefined + let enterAnim: AnimationPlaybackControls | undefined + let leaveAnim: AnimationPlaybackControls | undefined + let titleRef: HTMLInputElement | undefined + let headerRef: HTMLDivElement | undefined + let enterRef: HTMLSpanElement | undefined + let leaveRef: HTMLSpanElement | undefined + + const clearHeaderAnim = () => { + headerAnim?.stop() + headerAnim = undefined + } + + const animateHeader = () => { + const el = headerRef + if (!el) return + + clearHeaderAnim() + if (!headerText.muted || reduce()) { + el.style.opacity = "1" + return + } + + headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 }) + headerAnim.finished.then(() => { + if (headerRef !== el) return + clearFadeStyles(el) + }) + } + + const clearTitleAnims = () => { + enterAnim?.stop() + enterAnim = undefined + leaveAnim?.stop() + leaveAnim = undefined + } + + const settleTitleEnter = () => { + if (enterRef) clearFadeStyles(enterRef) + } + + const hideLeave = () => { + if (!leaveRef) return + leaveRef.style.opacity = "0" + leaveRef.style.filter = "" + leaveRef.style.transform = "" + } + + const animateEnterSpan = () => { + if (!enterRef) return + if (reduce()) { + settleTitleEnter() + return + } + enterAnim = animate( + enterRef, + { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] }, + FAST_SPRING, + ) + enterAnim.finished.then(() => settleTitleEnter()) + } + + const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) + setHeaderText({ value: nextTitle, muted: nextMuted }) + + if (reduce()) { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + settleTitleEnter() + return + } + + if (leaveRef) { + leaveAnim = animate( + leaveRef, + { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] }, + FAST_SPRING, + ) + leaveAnim.finished.then(() => { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + }) + } + + animateEnterSpan() + } + + const fadeInTitle = (nextTitle: string, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + animateEnterSpan() + } + + const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => { + clearTitleAnims() + setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false }) + settleTitleEnter() + } + + createEffect( + on(props.showHeader, (show, prev) => { + if (!show) { + clearHeaderAnim() + return + } + if (show === prev) return + animateHeader() + }), + ) + + createEffect( + on( + () => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const, + ([nextSession, nextTitle, nextMuted]) => { + if (nextSession !== headerText.session) { + setHeaderText("session", nextSession) + if (nextTitle && nextMuted) { + fadeInTitle(nextTitle, nextMuted) + return + } + snapTitle(nextTitle, nextMuted) + return + } + if (nextTitle === headerText.value && nextMuted === headerText.muted) return + if (!nextTitle) { + snapTitle(undefined, false) + return + } + if (!headerText.value) { + fadeInTitle(nextTitle, nextMuted) + return + } + if (title.saving || title.editing) { + snapTitle(nextTitle, nextMuted) + return + } + crossfadeTitle(nextTitle, nextMuted) + }, + ), + ) + + onCleanup(() => { + clearHeaderAnim() + clearTitleAnims() + }) + + const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed")) + + createEffect( + on( + props.sessionKey, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!props.sessionID()) return + setTitle({ editing: true, draft: props.titleValue() ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const id = props.sessionID() + if (!id) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (props.titleValue() ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID: id, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((session) => session.id === id) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: toastError(err), + }) + }) + } + + const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => { + if (params.id !== sessionID) return + if (parentID) { + navigate(`/${params.dir}/session/${parentID}`) + return + } + if (nextSessionID) { + navigate(`/${params.dir}/session/${nextSessionID}`) + return + } + navigate(`/${params.dir}/session`) + } + + const archiveSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return + + const sessions = sync.data.session ?? [] + const index = sessions.findIndex((item) => item.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + await sdk.client.session + .update({ sessionID, time: { archived: Date.now() } }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((item) => item.id === sessionID) + if (index !== -1) draft.session.splice(index, 1) + }), + ) + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + }) + .catch((err) => { + showToast({ + title: language.t("common.requestFailed"), + description: toastError(err), + }) + }) + } + + const deleteSession = async (sessionID: string) => { + const session = sync.session.get(sessionID) + if (!session) return false + + const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived) + const index = sessions.findIndex((item) => item.id === sessionID) + const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) + + const result = await sdk.client.session + .delete({ sessionID }) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("session.delete.failed.title"), + description: toastError(err), + }) + return false + }) + + if (!result) return false + + sync.set( + produce((draft) => { + const removed = new Set([sessionID]) + const byParent = new Map() + + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((item) => !removed.has(item.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = props.parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(input: { sessionID: string }) { + const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) + + const handleDelete = async () => { + await deleteSession(input.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } + + return ( + +
{ + headerRef = el + el.style.opacity = "0" + }} + class="pointer-events-none absolute inset-x-0 top-0 z-30" + > +
+
+
+ +
+ +
+
+ + + + + {headerText.value} + + + {headerText.prev} + + + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + + +
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )}> + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
+ ) +} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index cc4c17ee21..c8bfc14053 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -191,8 +191,8 @@ export function TerminalPanel() { {(id) => ( - - {(pty) => } + + {(pty) => } )} @@ -217,10 +217,10 @@ export function TerminalPanel() {
{(id) => ( - + {(pty) => (
- terminal.clone(id)} /> + terminal.clone(id)} />
)}
@@ -229,14 +229,14 @@ export function TerminalPanel() {
- + {(draggedId) => ( - + {(t) => (
{terminalTabLabel({ - title: t().title, - titleNumber: t().titleNumber, + title: t.title, + titleNumber: t.titleNumber, t: language.t as (key: string, vars?: Record) => string, })}
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index b8ddeda823..461351878b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -261,35 +261,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }), ]) - const isAutoAcceptActive = () => { - const sessionID = params.id - if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) - return permission.isAutoAcceptingDirectory(sdk.directory) - } - const permissionCommands = createMemo(() => [ permissionsCommand({ id: "permissions.autoaccept", - title: isAutoAcceptActive() - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), + title: + params.id && permission.isAutoAccepting(params.id, sdk.directory) + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), keybind: "mod+shift+a", - disabled: false, + disabled: !params.id || !permission.permissionsEnabled(), onSelect: () => { const sessionID = params.id - if (sessionID) { - permission.toggleAutoAccept(sessionID, sdk.directory) - } else { - permission.toggleAutoAcceptDirectory(sdk.directory) - } - const active = sessionID - ? permission.isAutoAccepting(sessionID, sdk.directory) - : permission.isAutoAcceptingDirectory(sdk.directory) + if (!sessionID) return + permission.toggleAutoAccept(sessionID, sdk.directory) showToast({ - title: active + title: permission.isAutoAccepting(sessionID, sdk.directory) ? language.t("toast.permissions.autoaccept.on.title") : language.t("toast.permissions.autoaccept.off.title"), - description: active + description: permission.isAutoAccepting(sessionID, sdk.directory) ? language.t("toast.permissions.autoaccept.on.description") : language.t("toast.permissions.autoaccept.off.description"), }) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 20e88a3ea3..278a1ba6e5 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,6 +1,5 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" -import { useLocation, useNavigate } from "@solidjs/router" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { messageIdFromHash } from "./message-id-from-hash" export { messageIdFromHash } from "./message-id-from-hash" @@ -16,7 +15,7 @@ export const useSessionHashScroll = (input: { setPendingMessage: (value: string | undefined) => void setActiveMessage: (message: UserMessage | undefined) => void setTurnStart: (value: number) => void - autoScroll: { pause: () => void; forceScrollToBottom: () => void } + autoScroll: { pause: () => void; snapToBottom: () => void } scroller: () => HTMLDivElement | undefined anchor: (id: string) => string scheduleScrollState: (el: HTMLDivElement) => void @@ -27,18 +26,13 @@ export const useSessionHashScroll = (input: { const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" - const location = useLocation() - const navigate = useNavigate() - const clearMessageHash = () => { - if (!location.hash) return - navigate(location.pathname + location.search, { replace: true }) + if (!window.location.hash) return + window.history.replaceState(null, "", window.location.pathname + window.location.search) } const updateHash = (id: string) => { - navigate(location.pathname + location.search + `#${input.anchor(id)}`, { - replace: true, - }) + window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -47,15 +41,15 @@ export const useSessionHashScroll = (input: { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const sticky = root.querySelector("[data-session-title]") - const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0 - const top = Math.max(0, a.top - b.top + root.scrollTop - inset) + const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) + const inset = Number.isNaN(title) ? 0 : title + // With column-reverse, scrollTop is negative — don't clamp to 0 + const top = a.top - b.top + root.scrollTop - inset root.scrollTo({ top, behavior }) return true } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { - console.log({ message, behavior }) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -103,9 +97,9 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = location.hash.slice(1) + const hash = window.location.hash.slice(1) if (!hash) { - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) return @@ -129,13 +123,26 @@ export const useSessionHashScroll = (input: { return } - input.autoScroll.forceScrollToBottom() + input.autoScroll.snapToBottom() const el = input.scroller() if (el) input.scheduleScrollState(el) } + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } + + const handler = () => { + if (!input.sessionID() || !input.messagesReady()) return + requestAnimationFrame(() => applyHash("auto")) + } + + window.addEventListener("hashchange", handler) + onCleanup(() => window.removeEventListener("hashchange", handler)) + }) + createEffect(() => { - location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -159,7 +166,6 @@ export const useSessionHashScroll = (input: { } } - if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -171,12 +177,6 @@ export const useSessionHashScroll = (input: { requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) - onMount(() => { - if (typeof window !== "undefined" && "scrollRestoration" in window.history) { - window.history.scrollRestoration = "manual" - } - }) - return { clearMessageHash, scrollToMessage, diff --git a/packages/app/src/utils/dom.ts b/packages/app/src/utils/dom.ts index 4f3724c7c9..4169eb7e5a 100644 --- a/packages/app/src/utils/dom.ts +++ b/packages/app/src/utils/dom.ts @@ -1,3 +1,12 @@ +export function isEditableTarget(target: EventTarget | null | undefined) { + if (!(target instanceof HTMLElement)) return false + if (/^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName)) return true + if (target.isContentEditable) return true + if (target.closest("[contenteditable='true']")) return true + if (target.closest("input, textarea, select")) return true + return false +} + export function getCharacterOffsetInLine(lineElement: Element, targetNode: Node, offset: number): number { const r = document.createRange() r.selectNodeContents(lineElement) diff --git a/packages/app/src/utils/same.ts b/packages/app/src/utils/same.ts deleted file mode 100644 index c956f92998..0000000000 --- a/packages/app/src/utils/same.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} diff --git a/packages/storybook/.storybook/mocks/app/context/command.ts b/packages/storybook/.storybook/mocks/app/context/command.ts index 1aa0423d3c..cc2dc7b451 100644 --- a/packages/storybook/.storybook/mocks/app/context/command.ts +++ b/packages/storybook/.storybook/mocks/app/context/command.ts @@ -4,8 +4,8 @@ const keybinds: Record = { "prompt.mode.normal": "mod+shift+e", "permissions.autoaccept": "mod+shift+a", "agent.cycle": "mod+.", - "model.choose": "mod+m", - "model.variant.cycle": "mod+shift+m", + "model.choose": "mod+'", + "model.variant.cycle": "shift+mod+d", } export function useCommand() { diff --git a/packages/ui/happydom.ts b/packages/ui/happydom.ts new file mode 100644 index 0000000000..ce010847d9 --- /dev/null +++ b/packages/ui/happydom.ts @@ -0,0 +1,3 @@ +import { GlobalRegistrator } from "@happy-dom/global-registrator" + +GlobalRegistrator.register() diff --git a/packages/ui/package.json b/packages/ui/package.json index db585bb008..9344eb43a0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,10 +25,14 @@ }, "scripts": { "typecheck": "tsgo --noEmit", + "test": "bun run test:unit", + "test:unit": "bun test --preload ./happydom.ts ./src/components", + "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src/components", "dev": "vite", "generate:tailwind": "bun run script/tailwind.ts" }, "devDependencies": { + "@happy-dom/global-registrator": "20.0.11", "@tailwindcss/vite": "catalog:", "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index 022b347e96..b69ce65084 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -9,19 +9,20 @@ display: inline-flex; flex-direction: row-reverse; align-items: baseline; - justify-content: flex-end; + justify-content: flex-start; line-height: inherit; width: var(--animated-number-width, 1ch); - overflow: hidden; - transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); + overflow: clip; + transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } [data-slot="animated-number-digit"] { display: inline-block; + flex-shrink: 0; width: 1ch; height: 1em; line-height: 1em; - overflow: hidden; + overflow: clip; vertical-align: baseline; -webkit-mask-image: linear-gradient( to bottom, @@ -46,7 +47,7 @@ flex-direction: column; transform: translateY(calc(var(--animated-number-offset, 10) * -1em)); transition-property: transform; - transition-duration: var(--animated-number-duration, 560ms); + transition-duration: var(--animated-number-duration, 600ms); transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)); } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index b5fceba256..dfe368b8bb 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 600 +const DURATION = 800 function normalize(value: number) { return ((value % 10) + 10) % 10 @@ -90,10 +90,35 @@ export function AnimatedNumber(props: { value: number; class?: string }) { ) const width = createMemo(() => `${digits().length}ch`) + const [exitingDigits, setExitingDigits] = createSignal([]) + let exitTimer: number | undefined + + createEffect( + on( + digits, + (current, prev) => { + if (prev && current.length < prev.length) { + setExitingDigits(prev.slice(current.length)) + clearTimeout(exitTimer) + exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION) + } else { + clearTimeout(exitTimer) + setExitingDigits([]) + } + }, + { defer: true }, + ), + ) + + const displayDigits = createMemo(() => { + const exiting = exitingDigits() + return exiting.length ? [...digits(), ...exiting] : digits() + }) + return ( - {(digit) => } + {(digit) => } ) diff --git a/packages/ui/src/components/animation-debug-panel.tsx b/packages/ui/src/components/animation-debug-panel.tsx new file mode 100644 index 0000000000..1c3cccd901 --- /dev/null +++ b/packages/ui/src/components/animation-debug-panel.tsx @@ -0,0 +1,122 @@ +import { createSignal } from "solid-js" +import { + getGrowDuration, + getCollapsibleDuration, + setGrowDuration, + setCollapsibleDuration, +} from "./motion" + +export function AnimationDebugPanel() { + const [grow, setGrow] = createSignal(getGrowDuration()) + const [collapsible, setCollapsible] = createSignal(getCollapsibleDuration()) + const [collapsed, setCollapsed] = createSignal(true) + const [dragging, setDragging] = createSignal(false) + const [pos, setPos] = createSignal({ x: 16, y: 16 }) + let dragOffset = { x: 0, y: 0 } + + const onPointerDown = (e: PointerEvent) => { + if ((e.target as HTMLElement).closest("button, input")) return + setDragging(true) + dragOffset = { x: e.clientX + pos().x, y: e.clientY + pos().y } + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) + } + + const onPointerMove = (e: PointerEvent) => { + if (!dragging()) return + setPos({ x: dragOffset.x - e.clientX, y: dragOffset.y - e.clientY }) + } + + const onPointerUp = () => setDragging(false) + + return ( +
+
+ + springs + + +
+ {!collapsed() && ( +
+ { + setGrow(v) + setGrowDuration(v) + }} + /> + { + setCollapsible(v) + setCollapsibleDuration(v) + }} + /> +
+ )} +
+ ) +} + +function SliderRow(props: { label: string; value: number; onChange: (v: number) => void }) { + return ( +
+ {props.label} + props.onChange(parseFloat(e.currentTarget.value))} + style={{ width: "100px", cursor: "pointer" }} + /> + + {props.value.toFixed(2)} + +
+ ) +} diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index 02be54d738..c3a62c59c2 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -8,54 +8,28 @@ justify-content: flex-start; [data-slot="basic-tool-tool-trigger-content"] { - width: auto; + width: 100%; + min-width: 0; display: flex; align-items: center; align-self: stretch; gap: 8px; } - [data-slot="basic-tool-tool-indicator"] { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - - [data-component="spinner"] { - width: 16px; - height: 16px; - } - } - - [data-slot="basic-tool-tool-spinner"] { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--text-weak); - - [data-component="spinner"] { - width: 16px; - height: 16px; - } - } - [data-slot="icon-svg"] { flex-shrink: 0; } [data-slot="basic-tool-tool-info"] { - flex: 0 1 auto; + flex: 1 1 auto; min-width: 0; font-size: 14px; } [data-slot="basic-tool-tool-info-structured"] { width: auto; + max-width: 100%; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -63,11 +37,12 @@ } [data-slot="basic-tool-tool-info-main"] { + flex: 0 1 auto; display: flex; - align-items: baseline; + align-items: center; gap: 8px; min-width: 0; - overflow: hidden; + overflow: clip; } [data-slot="basic-tool-tool-title"] { @@ -80,21 +55,14 @@ letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - &.capitalize { - text-transform: capitalize; - } - - &.agent-title { - color: var(--text-strong); - font-weight: var(--font-weight-medium); - } } [data-slot="basic-tool-tool-subtitle"] { - flex-shrink: 1; + display: inline-block; + flex: 0 1 auto; + max-width: 100%; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; font-family: var(--font-family-sans); font-size: 14px; @@ -138,8 +106,7 @@ [data-slot="basic-tool-tool-arg"] { flex-shrink: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + overflow: clip; white-space: nowrap; font-family: var(--font-family-sans); font-size: 14px; diff --git a/packages/ui/src/components/basic-tool.stories.tsx b/packages/ui/src/components/basic-tool.stories.tsx deleted file mode 100644 index 9d9d97acfe..0000000000 --- a/packages/ui/src/components/basic-tool.stories.tsx +++ /dev/null @@ -1,133 +0,0 @@ -// @ts-nocheck -import { createSignal } from "solid-js" -import * as mod from "./basic-tool" -import { create } from "../storybook/scaffold" - -const docs = `### Overview -Expandable tool panel with a structured trigger and optional details. - -Use structured triggers for consistent layout; custom triggers allowed. - -### API -- Required: \`icon\` and \`trigger\` (structured or custom JSX). -- Optional: \`status\`, \`defaultOpen\`, \`forceOpen\`, \`defer\`, \`locked\`. - -### Variants and states -- Pending/running status animates the title via TextShimmer. - -### Behavior -- Uses Collapsible; can defer content rendering until open. -- Locked state prevents closing. - -### Accessibility -- TODO: confirm trigger semantics and aria labeling. - -### Theming/tokens -- Uses \`data-component="tool-trigger"\` and related slots. - -` - -const story = create({ - title: "UI/Basic Tool", - mod, - args: { - icon: "mcp", - defaultOpen: true, - trigger: { - title: "Basic Tool", - subtitle: "Example subtitle", - args: ["--flag", "value"], - }, - children: "Details content", - }, -}) - -export default { - title: "UI/Basic Tool", - id: "components-basic-tool", - component: story.meta.component, - tags: ["autodocs"], - parameters: { - docs: { - description: { - component: docs, - }, - }, - }, -} - -export const Basic = story.Basic - -export const Pending = { - args: { - status: "pending", - trigger: { - title: "Running tool", - subtitle: "Working...", - }, - children: "Progress details", - }, -} - -export const Locked = { - args: { - locked: true, - trigger: { - title: "Locked tool", - subtitle: "Cannot close", - }, - children: "Locked details", - }, -} - -export const Deferred = { - args: { - defer: true, - defaultOpen: false, - trigger: { - title: "Deferred tool", - subtitle: "Content mounts on open", - }, - children: "Deferred content", - }, -} - -export const ForceOpen = { - args: { - forceOpen: true, - trigger: { - title: "Forced open", - subtitle: "Cannot close", - }, - children: "Forced content", - }, -} - -export const HideDetails = { - args: { - hideDetails: true, - trigger: { - title: "Summary only", - subtitle: "Details hidden", - }, - children: "Hidden content", - }, -} - -export const SubtitleAction = { - render: () => { - const [message, setMessage] = createSignal("Subtitle not clicked") - return ( -
-
{message()}
- setMessage("Subtitle clicked")} - > - Subtitle action details - -
- ) - }, -} diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 4ad91824da..3210b48701 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,8 +1,20 @@ -import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" +import { + createEffect, + createSignal, + For, + Match, + on, + onCleanup, + onMount, + Show, + splitProps, + Switch, + type JSX, +} from "solid-js" +import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion" import { Collapsible } from "./collapsible" -import type { IconProps } from "./icon" import { TextShimmer } from "./text-shimmer" +import { hold } from "./tool-utils" export type TriggerTitle = { title: string @@ -20,26 +32,99 @@ const isTriggerTitle = (val: any): val is TriggerTitle => { ) } -export interface BasicToolProps { - icon: IconProps["name"] +interface ToolCallPanelBaseProps { + icon: string trigger: TriggerTitle | JSX.Element children?: JSX.Element status?: string + animate?: boolean hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean defer?: boolean locked?: boolean - animated?: boolean + watchDetails?: boolean + springContent?: boolean onSubtitleClick?: () => void } -const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } +function ToolCallTriggerBody(props: { + trigger: TriggerTitle | JSX.Element + pending: boolean + onSubtitleClick?: () => void + arrow?: boolean +}) { + return ( +
+
+
+ + + {(trigger) => ( +
+
+ + + + + + { + if (!props.onSubtitleClick) return + e.stopPropagation() + props.onSubtitleClick() + }} + > + {trigger().subtitle} + + + + + {(arg) => ( + + {arg} + + )} + + + +
+ {trigger().action} +
+ )} +
+ {props.trigger as JSX.Element} +
+
+
+ + + +
+ ) +} -export function BasicTool(props: BasicToolProps) { +function ToolCallPanel(props: ToolCallPanelBaseProps) { const [open, setOpen] = createSignal(props.defaultOpen ?? false) const [ready, setReady] = createSignal(open()) - const pending = () => props.status === "pending" || props.status === "running" + const pendingRaw = () => props.status === "pending" || props.status === "running" + const pending = hold(pendingRaw, 1000) + const watchDetails = () => props.watchDetails !== false let frame: number | undefined @@ -59,7 +144,7 @@ export function BasicTool(props: BasicToolProps) { on( open, (value) => { - if (!props.defer) return + if (!props.defer || props.springContent) return if (!value) { cancel() setReady(false) @@ -77,36 +162,110 @@ export function BasicTool(props: BasicToolProps) { ), ) - // Animated height for collapsible open/close + // Animated content height — single springValue drives all height changes let contentRef: HTMLDivElement | undefined - let heightAnim: AnimationPlaybackControls | undefined + let bodyRef: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let observer: ResizeObserver | undefined + let resizeFrame: number | undefined const initialOpen = open() + const heightSpring = tunableSpringValue(0, COLLAPSIBLE_SPRING) + + const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)) + + const doOpen = () => { + if (!contentRef || !bodyRef) return + contentRef.style.display = "" + // Ensure fade starts from 0 if content was hidden (first open or after close cleared styles) + if (bodyRef.style.opacity === "") { + bodyRef.style.opacity = "0" + bodyRef.style.filter = "blur(2px)" + } + const next = read() + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING) + fadeAnim.finished.then(() => { + if (!bodyRef) return + bodyRef.style.opacity = "" + bodyRef.style.filter = "" + }) + heightSpring.set(next) + } + + const doClose = () => { + if (!contentRef || !bodyRef) return + fadeAnim?.stop() + fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING) + fadeAnim.finished.then(() => { + if (!contentRef || open()) return + contentRef.style.display = "none" + }) + heightSpring.set(0) + } + + const grow = () => { + if (!contentRef || !open()) return + const next = read() + if (Math.abs(next - heightSpring.get()) < 1) return + heightSpring.set(next) + } + + onMount(() => { + if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return + + const offChange = heightSpring.on("change", (v) => { + if (!contentRef) return + contentRef.style.height = `${Math.max(0, Math.ceil(v))}px` + }) + onCleanup(() => { + offChange() + }) + + if (watchDetails()) { + observer = new ResizeObserver(() => { + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + grow() + }) + }) + observer.observe(bodyRef) + } + + if (!open()) return + if (contentRef.style.display !== "none") { + const next = read() + heightSpring.jump(next) + contentRef.style.height = `${next}px` + return + } + let mountFrame: number | undefined = requestAnimationFrame(() => { + mountFrame = undefined + if (!open()) return + doOpen() + }) + onCleanup(() => { + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + }) + }) createEffect( on( open, (isOpen) => { - if (!props.animated || !contentRef) return - heightAnim?.stop() - if (isOpen) { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "auto" }, SPRING) - heightAnim.finished.then(() => { - if (!contentRef || !open()) return - contentRef.style.overflow = "visible" - contentRef.style.height = "auto" - }) - } else { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "0px" }, SPRING) - } + if (!props.springContent || props.animate === false || !contentRef) return + if (isOpen) doOpen() + else doClose() }, { defer: true }, ), ) onCleanup(() => { - heightAnim?.stop() + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + fadeAnim?.stop() + heightSpring.destroy() }) const handleOpenChange = (value: boolean) => { @@ -118,85 +277,34 @@ export function BasicTool(props: BasicToolProps) { return ( -
-
-
- - - {(trigger) => ( -
-
- - - - - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {trigger().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- {trigger().action} -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
+
- +
- {props.children} +
+ {props.children} +
- + - {props.children} + +
{props.children}
+
@@ -222,6 +330,60 @@ function args(input: Record | undefined) { .slice(0, 3) } +export interface ToolCallRowProps { + variant: "row" + icon: string + trigger: TriggerTitle | JSX.Element + status?: string + animate?: boolean + onSubtitleClick?: () => void + open?: boolean + showArrow?: boolean + onOpenChange?: (value: boolean) => void +} +export interface ToolCallPanelProps extends Omit { + variant: "panel" +} +export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps +function ToolCallRoot(props: ToolCallProps) { + const pending = () => props.status === "pending" || props.status === "running" + if (props.variant === "row") { + return ( + +
+ +
+
+ } + > + {(onOpenChange) => ( + + + + + + )} + + ) + } + + const [, rest] = splitProps(props, ["variant"]) + return +} +export const ToolCall = ToolCallRoot + export function GenericTool(props: { tool: string status?: string @@ -229,7 +391,8 @@ export function GenericTool(props: { input?: Record }) { return ( - ) } diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index bab2c4f926..1a86338bdc 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -8,14 +8,18 @@ border-radius: var(--radius-md); overflow: visible; - &.tool-collapsible { - gap: 8px; + &.tool-collapsible [data-slot="collapsible-trigger"] { + height: 37px; + } + + &.tool-collapsible [data-slot="basic-tool-content-inner"] { + padding-top: 0; } [data-slot="collapsible-trigger"] { width: 100%; display: flex; - height: 32px; + height: 36px; padding: 0; align-items: center; align-self: stretch; @@ -23,6 +27,17 @@ user-select: none; color: var(--text-base); + > [data-component="tool-trigger"][data-arrow] { + width: auto; + max-width: 100%; + flex: 0 1 auto; + + [data-slot="basic-tool-tool-trigger-content"] { + width: auto; + max-width: 100%; + } + } + [data-slot="collapsible-arrow"] { opacity: 0; transition: opacity 0.15s ease; @@ -50,9 +65,6 @@ line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); - /* &:hover { */ - /* background-color: var(--surface-base); */ - /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -82,16 +94,16 @@ } [data-slot="collapsible-content"] { - overflow: hidden; - /* animation: slideUp 250ms ease-out; */ + overflow: clip; &[data-expanded] { overflow: visible; } - /* &[data-expanded] { */ - /* animation: slideDown 250ms ease-out; */ - /* } */ + /* JS-animated content: overflow managed by animate() */ + &[data-spring-content] { + overflow: clip; + } } &[data-variant="ghost"] { @@ -103,9 +115,6 @@ border: none; padding: 0; - /* &:hover { */ - /* color: var(--text-strong); */ - /* } */ &:focus-visible { outline: none; background-color: var(--surface-raised-base-hover); @@ -122,21 +131,3 @@ } } } - -@keyframes slideDown { - from { - height: 0; - } - to { - height: var(--kb-collapsible-content-height); - } -} - -@keyframes slideUp { - from { - height: var(--kb-collapsible-content-height); - } - to { - height: 0; - } -} diff --git a/packages/ui/src/components/composer-island.stories.tsx b/packages/ui/src/components/composer-island.stories.tsx new file mode 100644 index 0000000000..b0be2b4f08 --- /dev/null +++ b/packages/ui/src/components/composer-island.stories.tsx @@ -0,0 +1,311 @@ +// @ts-nocheck +import { createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { ComposerIsland } from "./composer-island" + +const docs = `### Overview +Composer island with a runtime/service API. + +Use the same component in Storybook and app code by swapping the \`runtime\` object: +- Storybook: mocked async handlers +- App: SDK-backed handlers + +### Runtime API +- \`submit\` / \`abort\` +- \`searchAt\` / \`searchSlash\` +- \`toggleAccept\` +- \`submitQuestion\` / \`rejectQuestion\` +- \`decidePermission\` +` + +export default { + title: "UI/ComposerIsland", + id: "components-composer-island", + component: ComposerIsland, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, +} + +const questions = [ + { + text: "Which editor do you use most often?", + options: [ + { label: "Neovim (Recommended)", description: "Fast keyboard-driven workflow" }, + { label: "VS Code", description: "Feature-rich and extensible" }, + { label: "Zed", description: "Lightweight modern editor" }, + ], + multiple: false, + }, + { + text: "Which testing frameworks should we add?", + options: [ + { label: "Vitest", description: "Fast unit testing" }, + { label: "Playwright", description: "E2E browser testing" }, + { label: "Testing Library", description: "Component testing" }, + ], + multiple: true, + }, +] + +const at = [ + { type: "file", path: "src/auth.ts", display: "src/auth.ts", recent: true }, + { type: "file", path: "src/middleware.ts", display: "src/middleware.ts" }, + { type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" }, + { type: "agent", name: "coder", display: "coder" }, + { type: "agent", name: "reviewer", display: "reviewer" }, +] + +const slash = [ + { + id: "help", + trigger: "help", + title: "Help", + description: "Show available commands", + type: "builtin", + source: "command", + }, + { + id: "clear", + trigger: "clear", + title: "Clear", + description: "Clear conversation", + type: "builtin", + source: "command", + }, + { + id: "review", + trigger: "review", + title: "Review", + description: "Review code changes", + type: "custom", + source: "skill", + }, + { + id: "test", + trigger: "test", + title: "Test", + description: "Run tests", + type: "custom", + source: "mcp", + keybind: "mod+t", + }, +] + +const todos = [ + { content: "Read auth module", status: "completed" as const }, + { content: "Refactor token logic", status: "in_progress" as const }, + { content: "Add tests", status: "pending" as const }, + { content: "Ship changes", status: "pending" as const }, +] + +const contexts = [ + { id: "c1", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "JWT expiry looks brittle" }, + { id: "c2", path: "src/routes/login.ts", comment: "Rate limit should be stricter" }, +] + +const dragCycle = [null, "image", "@mention"] as const + +const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +export const Interactive = () => { + const [mode, setMode] = createSignal<"input" | "question" | "permission">("input") + const [tab, setTab] = createSignal(0) + const [showTodos, setShowTodos] = createSignal(true) + const [todoCollapsed, setTodoCollapsed] = createSignal(false) + const [showContext, setShowContext] = createSignal(true) + const [dragIndex, setDragIndex] = createSignal(0) + const [working, setWorking] = createSignal(false) + const [accepting, setAccepting] = createSignal(false) + const [agent, setAgent] = createSignal("ask") + const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex") + const [variant, setVariant] = createSignal("default") + const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] }) + + const q = createMemo(() => questions[tab()] ?? questions[0]) + const drag = createMemo(() => dragCycle[dragIndex()] ?? null) + + const runtime = { + submit: async (input) => { + setWorking(true) + await wait(600) + setWorking(false) + console.log("submit", input) + }, + abort: () => { + setWorking(false) + console.log("abort") + }, + toggleAccept: () => { + setAccepting((value) => !value) + }, + runSlash: (cmd) => { + if (cmd.type !== "builtin") return false + console.log("slash:run", cmd.id) + return true + }, + historyRead: (mode) => history()[mode], + historyWrite: (mode, list) => { + setHistory((prev) => ({ ...prev, [mode]: list })) + }, + searchAt: async (filter: string) => { + await wait(120) + return at.filter((item) => { + const value = item.type === "agent" ? item.name : item.path + return value.toLowerCase().includes(filter.toLowerCase()) + }) + }, + searchSlash: async (filter: string) => { + await wait(120) + return slash.filter((item) => item.trigger.toLowerCase().includes(filter.toLowerCase())) + }, + decidePermission: async (response) => { + console.log("permission", response) + await wait(300) + setMode("input") + }, + submitQuestion: async (answers) => { + console.log("question answers", answers) + await wait(300) + setMode("input") + setTab(0) + }, + rejectQuestion: async () => { + await wait(150) + setMode("input") + setTab(0) + }, + openContext: (item) => { + console.log("open context", item) + }, + removeContext: (item) => { + console.log("remove context", item) + }, + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target + if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) return + if (target instanceof HTMLElement && target.isContentEditable) return + + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + + let hit = false + if (event.key === "1") { + setMode("input") + hit = true + } + if (event.key === "2") { + setMode("question") + hit = true + } + if (event.key === "3") { + setMode("permission") + hit = true + } + if (event.key === "4") { + setTodoCollapsed((value) => !value) + hit = true + } + if (event.key === "5") { + setShowTodos((value) => !value) + hit = true + } + if (event.key === "6") { + setShowContext((value) => !value) + hit = true + } + if (event.key === "7") { + setDragIndex((value) => (value + 1) % dragCycle.length) + hit = true + } + + if (!hit) return + event.preventDefault() + event.stopPropagation() + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + return ( +
+
+ + + + + + + +
+ + setTab((value) => Math.max(0, value - 1))} + onQuestionNext={() => setTab((value) => Math.min(questions.length - 1, value + 1))} + onQuestionJump={setTab} + onQuestionDismiss={() => { + setMode("input") + setTab(0) + }} + agentName="Ask" + modelName="GPT-4" + variant="default" + todos={todos} + showTodos={showTodos()} + todoCollapsed={todoCollapsed()} + onTodoCollapseChange={setTodoCollapsed} + contextItems={showContext() ? contexts : []} + forceDragType={drag()} + permissionDescription="Modify files, including edits, writes, patches, and multi-edits" + permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]} + /> +
+ ) +} diff --git a/packages/ui/src/components/composer-island.tsx b/packages/ui/src/components/composer-island.tsx new file mode 100644 index 0000000000..3b88d1c7e9 --- /dev/null +++ b/packages/ui/src/components/composer-island.tsx @@ -0,0 +1,28 @@ +import type { Component } from "solid-js" +import { NewComposer } from "./new-composer" +import type { NewComposerProps } from "./new-composer" + +/** + * Composer Island is now backed by `NewComposer`. + * + * This keeps the existing import path (`@opencode-ai/ui/composer-island`) stable + * while using the split/runtime-ready architecture under the hood. + */ +export type ComposerIslandProps = NewComposerProps + +export const ComposerIsland: Component = (props) => { + return +} + +export type { + AtOption, + ComposerMode, + ComposerPart, + ComposerRuntime, + ComposerSource, + ComposerSubmit, + ContextItem, + ImageAttachment, + SlashCommand, + TodoItem, +} from "./new-composer/types" diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx new file mode 100644 index 0000000000..8b8a3f68c0 --- /dev/null +++ b/packages/ui/src/components/context-tool-results.tsx @@ -0,0 +1,290 @@ +import { createEffect, createMemo, createSignal, For, on, onCleanup, onMount } from "solid-js" +import type { ToolPart } from "@opencode-ai/sdk/v2" +import { getFilename } from "@opencode-ai/util/path" +import { useI18n } from "../context/i18n" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" +import { ToolCall } from "./basic-tool" +import { ToolStatusTitle } from "./tool-status-title" +import { AnimatedCountList } from "./tool-count-summary" +import { RollingResults } from "./rolling-results" +import { + animate, + type AnimationPlaybackControls, + clearFadeStyles, + clearMaskStyles, + COLLAPSIBLE_SPRING, + GROW_SPRING, + WIPE_MASK, +} from "./motion" +import { useSpring } from "./motion-spring" +import { busy } from "./tool-utils" + +function contextToolLabel(part: ToolPart): { action: string; detail: string } { + const state = part.state + const title = "title" in state ? (state.title as string | undefined) : undefined + const input = state.input + if (part.tool === "read") { + const path = input?.filePath as string | undefined + return { action: "Read", detail: title || (path ? getFilename(path) : "") } + } + if (part.tool === "grep") { + const pattern = input?.pattern as string | undefined + return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") } + } + if (part.tool === "glob") { + const pattern = input?.pattern as string | undefined + return { action: "Find", detail: title || (pattern ?? "") } + } + if (part.tool === "list") { + const path = input?.path as string | undefined + return { action: "List", detail: title || (path ? getFilename(path) : "") } + } + return { action: part.tool, detail: title || "" } +} + +function contextToolSummary(parts: ToolPart[]) { + let read = 0 + let search = 0 + let list = 0 + for (const part of parts) { + if (part.tool === "read") read++ + else if (part.tool === "glob" || part.tool === "grep") search++ + else if (part.tool === "list") list++ + } + return { read, search, list } +} + +export function ContextToolGroupHeader(props: { + parts: ToolPart[] + pending: boolean + open: boolean + onOpenChange: (value: boolean) => void +}) { + const i18n = useI18n() + const summary = createMemo(() => contextToolSummary(props.parts)) + return ( + { + if (!props.pending) props.onOpenChange(v) + }} + trigger={ +
+ + + + + + + + +
+ } + /> + ) +} + +export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) { + let contentRef: HTMLDivElement | undefined + let bodyRef: HTMLDivElement | undefined + let scrollRef: HTMLDivElement | undefined + let heightAnim: AnimationPlaybackControls | undefined + let fadeAnim: AnimationPlaybackControls | undefined + + const FADE = 12 + + const updateMask = () => { + if (!scrollRef) return + const { scrollTop, scrollHeight, clientHeight } = scrollRef + const overflow = scrollHeight - clientHeight + if (overflow <= 1) { + scrollRef.style.maskImage = "" + scrollRef.style.webkitMaskImage = "" + return + } + const top = scrollTop > 1 + const bottom = scrollTop < overflow - 1 + const mask = + top && bottom + ? `linear-gradient(to bottom, transparent 0, black ${FADE}px, black calc(100% - ${FADE}px), transparent 100%)` + : top + ? `linear-gradient(to bottom, transparent 0, black ${FADE}px)` + : bottom + ? `linear-gradient(to bottom, black calc(100% - ${FADE}px), transparent 100%)` + : "" + scrollRef.style.maskImage = mask + scrollRef.style.webkitMaskImage = mask + } + + createEffect( + on( + () => props.expanded, + (isOpen) => { + if (!contentRef || !bodyRef) return + heightAnim?.stop() + fadeAnim?.stop() + if (isOpen) { + contentRef.style.display = "" + bodyRef.style.opacity = "0" + bodyRef.style.filter = "blur(2px)" + const h = bodyRef.getBoundingClientRect().height + heightAnim = animate(contentRef, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING) + fadeAnim = animate(bodyRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING) + heightAnim.finished + .catch(() => {}) + .then(() => { + if (!contentRef || !props.expanded) return + contentRef.style.height = "auto" + updateMask() + }) + } else { + const h = contentRef.getBoundingClientRect().height + heightAnim = animate(contentRef, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING) + fadeAnim = animate(bodyRef, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING) + heightAnim.finished + .catch(() => {}) + .then(() => { + if (!contentRef || props.expanded) return + contentRef.style.display = "none" + }) + } + }, + { defer: true }, + ), + ) + + onCleanup(() => { + heightAnim?.stop() + fadeAnim?.stop() + }) + + return ( +
+
+
+ + {(part) => { + const label = createMemo(() => contextToolLabel(part)) + return ( +
+ {label().action} + {label().detail} +
+ ) + }} +
+
+
+
+ ) +} + +export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) { + const wiped = new Set() + const [mounted, setMounted] = createSignal(false) + onMount(() => setMounted(true)) + const reduce = prefersReducedMotion + const show = () => mounted() && props.pending + const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) + const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) + return ( +
+ part.callID || part.id} + render={(part) => { + const label = createMemo(() => contextToolLabel(part)) + const k = part.callID || part.id + return ( +
+ {label().action} + {(() => { + const [detailRef, setDetailRef] = createSignal() + createEffect(() => { + const el = detailRef() + const d = label().detail + if (!el || !d) return + if (wiped.has(k)) return + wiped.add(k) + if (reduce()) return + el.style.maskImage = WIPE_MASK + el.style.webkitMaskImage = WIPE_MASK + el.style.maskSize = "240% 100%" + el.style.webkitMaskSize = "240% 100%" + el.style.maskRepeat = "no-repeat" + el.style.webkitMaskRepeat = "no-repeat" + el.style.maskPosition = "100% 0%" + el.style.webkitMaskPosition = "100% 0%" + animate( + el, + { + opacity: [0, 1], + filter: ["blur(2px)", "blur(0px)"], + transform: ["translateX(-0.06em)", "translateX(0)"], + maskPosition: "0% 0%", + }, + GROW_SPRING, + ).finished.then(() => { + if (!el) return + clearFadeStyles(el) + clearMaskStyles(el) + }) + }) + return ( + + {label().detail} + + ) + })()} +
+ ) + }} + /> +
+ ) +} diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx new file mode 100644 index 0000000000..ec4921ab3a --- /dev/null +++ b/packages/ui/src/components/grow-box.tsx @@ -0,0 +1,426 @@ +import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" +import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" + +export interface GrowBoxProps { + children: JSX.Element + /** Enable animation. When false, content shows immediately at full height. */ + animate?: boolean + /** Animate height from 0 to content height. Default: true. */ + grow?: boolean + /** Keep watching body size and animate subsequent height changes. Default: false. */ + watch?: boolean + /** Fade in body content (opacity + blur). Default: true. */ + fade?: boolean + /** Top padding in px on the body wrapper. Default: 0. */ + gap?: number + /** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */ + autoHeight?: boolean + /** Controlled visibility for animating open/close without unmounting children. */ + open?: boolean + /** Animate controlled open/close changes after mount. Default: true. */ + animateToggle?: boolean + /** data-slot attribute on the root div. */ + slot?: string + /** CSS class on the root div. */ + class?: string + /** Override mount and resize spring config. Default: GROW_SPRING. */ + spring?: SpringConfig + /** Override controlled open/close spring config. Default: spring. */ + toggleSpring?: SpringConfig + /** Show a temporary bottom edge fade while height animation is running. */ + edge?: boolean + /** Edge fade height in px. Default: 20. */ + edgeHeight?: number + /** Edge fade opacity (0-1). Default: 1. */ + edgeOpacity?: number + /** Delay before edge fades out after height settles. Default: 320. */ + edgeIdle?: number + /** Edge fade-out duration in seconds. Default: 0.24. */ + edgeFade?: number + /** Edge fade-in duration in seconds. Default: 0.2. */ + edgeRise?: number +} + +/** + * Wraps children in a container that animates from zero height on mount. + * + * Includes a ResizeObserver so content changes after mount are also spring-animated. + * Used for timeline turns, assistant part groups, and user messages. + */ +export function GrowBox(props: GrowBoxProps) { + const reduce = prefersReducedMotion + const spring = () => props.spring ?? GROW_SPRING + const toggleSpring = () => props.toggleSpring ?? spring() + let mode: "mount" | "toggle" = "mount" + let root: HTMLDivElement | undefined + let body: HTMLDivElement | undefined + let fadeAnim: AnimationPlaybackControls | undefined + let edgeRef: HTMLDivElement | undefined + let edgeAnim: AnimationPlaybackControls | undefined + let edgeTimer: ReturnType | undefined + let edgeOn = false + let mountFrame: number | undefined + let resizeFrame: number | undefined + let observer: ResizeObserver | undefined + let springTarget = -1 + const height = tunableSpringValue(0, { + type: "spring", + get visualDuration() { + return (mode === "toggle" ? toggleSpring() : spring()).visualDuration + }, + get bounce() { + return (mode === "toggle" ? toggleSpring() : spring()).bounce + }, + }) + + const gap = () => Math.max(0, props.gap ?? 0) + const grow = () => props.grow !== false + const watch = () => props.watch === true + const open = () => props.open !== false + const animateToggle = () => props.animateToggle !== false + const edge = () => props.edge === true + const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20) + const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1)) + const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320) + const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24) + const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2) + const animated = () => props.animate !== false && !reduce() + const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0 + + const stopEdgeTimer = () => { + if (edgeTimer === undefined) return + clearTimeout(edgeTimer) + edgeTimer = undefined + } + + const hideEdge = (instant = false) => { + stopEdgeTimer() + if (!edgeRef) { + edgeOn = false + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (instant || reduce()) { + edgeRef.style.opacity = "0" + edgeOn = false + return + } + if (!edgeOn) { + edgeRef.style.opacity = "0" + return + } + const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 }) + edgeAnim = current + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = "0" + edgeOn = false + }) + } + + const showEdge = () => { + stopEdgeTimer() + if (!edgeRef) return + if (reduce()) { + edgeRef.style.opacity = `${edgeOpacity()}` + edgeOn = true + return + } + if (edgeOn && edgeAnim === undefined) { + edgeRef.style.opacity = `${edgeOpacity()}` + return + } + edgeAnim?.stop() + edgeAnim = undefined + if (!edgeOn) edgeRef.style.opacity = "0" + const current = animate( + edgeRef, + { opacity: edgeOpacity() }, + { type: "spring", visualDuration: edgeRise(), bounce: 0 }, + ) + edgeAnim = current + edgeOn = true + current.finished + .catch(() => {}) + .finally(() => { + if (edgeAnim !== current) return + edgeAnim = undefined + if (!edgeRef) return + edgeRef.style.opacity = `${edgeOpacity()}` + }) + } + + const queueEdgeHide = () => { + stopEdgeTimer() + if (!edgeOn) return + if (edgeIdle() <= 0) { + hideEdge() + return + } + edgeTimer = setTimeout(() => { + edgeTimer = undefined + hideEdge() + }, edgeIdle()) + } + + const hideBody = () => { + if (!body) return + body.style.opacity = "0" + body.style.filter = "blur(2px)" + } + + const clearBody = () => { + if (!body) return + body.style.opacity = "" + body.style.filter = "" + } + + const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { + if (props.fade === false || !body) return + if (reduce()) { + clearBody() + return + } + hideBody() + fadeAnim?.stop() + fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) + fadeAnim.finished.then(() => { + if (!body || !open()) return + clearBody() + }) + } + + const setInstant = (visible: boolean) => { + const next = visible ? targetHeight() : 0 + springTarget = next + height.jump(next) + root!.style.height = visible ? "" : "0px" + root!.style.overflow = visible ? "" : "clip" + hideEdge(true) + if (visible || props.fade === false) clearBody() + else hideBody() + } + + const currentHeight = () => { + if (!root) return 0 + const v = root.style.height + if (v && v !== "auto") { + const n = Number.parseFloat(v) + if (!Number.isNaN(n)) return n + } + return Math.max(0, root.getBoundingClientRect().height) + } + + const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) + + const setHeight = (nextMode: "mount" | "toggle" = "mount") => { + if (!root || !open()) return + const next = targetHeight() + if (reduce()) { + springTarget = next + height.jump(next) + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + root.style.height = "auto" + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + if (next === springTarget) return + const prev = currentHeight() + if (Math.abs(next - prev) < 1) { + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + } + return + } + root.style.overflow = "clip" + springTarget = next + mode = nextMode + height.set(next) + } + + onMount(() => { + if (!root || !body) return + + const offChange = height.on("change", (next) => { + if (!root) return + root.style.height = `${Math.max(0, next)}px` + }) + const offStart = height.on("animationStart", () => { + if (!root) return + root.style.overflow = "clip" + root.style.willChange = "height" + root.style.contain = "layout style" + if (edgeReady()) showEdge() + }) + const offComplete = height.on("animationComplete", () => { + if (!root) return + root.style.willChange = "" + root.style.contain = "" + if (!open()) { + springTarget = 0 + root.style.height = "0px" + root.style.overflow = "clip" + return + } + const next = targetHeight() + springTarget = next + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + if (edgeReady()) queueEdgeHide() + return + } + root.style.height = "auto" + root.style.overflow = "visible" + if (edgeReady()) queueEdgeHide() + }) + + onCleanup(() => { + offComplete() + offStart() + offChange() + }) + + if (!animated()) { + setInstant(open()) + return + } + + if (props.fade !== false) hideBody() + hideEdge(true) + + if (!open()) { + root.style.height = "0px" + root.style.overflow = "clip" + } else { + if (grow()) { + root.style.height = "0px" + root.style.overflow = "clip" + } else { + root.style.height = "auto" + root.style.overflow = "visible" + } + mountFrame = requestAnimationFrame(() => { + mountFrame = undefined + fadeBodyIn("mount") + if (grow()) setHeight("mount") + }) + } + if (watch()) { + observer = new ResizeObserver(() => { + if (!open()) return + if (resizeFrame !== undefined) return + resizeFrame = requestAnimationFrame(() => { + resizeFrame = undefined + setHeight("mount") + }) + }) + observer.observe(body) + } + }) + + createEffect( + on( + () => props.open, + (value) => { + if (value === undefined) return + if (!root || !body) return + if (!animateToggle() || reduce()) { + setInstant(value) + return + } + fadeAnim?.stop() + if (!value) hideEdge(true) + if (!value) { + const next = currentHeight() + if (Math.abs(next - height.get()) >= 1) { + springTarget = next + height.jump(next) + root.style.height = `${next}px` + } + if (props.fade !== false) { + fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring()) + } + root.style.overflow = "clip" + springTarget = 0 + mode = "toggle" + height.set(0) + return + } + fadeBodyIn("toggle") + setHeight("toggle") + }, + { defer: true }, + ), + ) + + createEffect(() => { + if (!edgeRef) return + edgeRef.style.height = `${edgeHeight()}px` + if (!animated() || !open() || edgeHeight() <= 0) { + hideEdge(true) + return + } + if (edge()) return + hideEdge() + }) + + createEffect(() => { + if (!root || !body) return + if (!reduce()) return + fadeAnim?.stop() + edgeAnim?.stop() + setInstant(open()) + }) + + onCleanup(() => { + stopEdgeTimer() + if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) + if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) + observer?.disconnect() + height.destroy() + fadeAnim?.stop() + edgeAnim?.stop() + edgeAnim = undefined + edgeOn = false + }) + + return ( +
+
0 ? `${gap()}px` : undefined }}> + {props.children} +
+
+
+ ) +} diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 01254f1189..bb41c74efb 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -44,19 +44,6 @@ function sanitize(html: string) { return DOMPurify.sanitize(html, config) } -function escape(text: string) { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/\"/g, """) - .replace(/'/g, "'") -} - -function fallback(markdown: string) { - return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
") -} - type CopyLabels = { copy: string copied: string @@ -250,7 +237,7 @@ export function Markdown( const [html] = createResource( () => local.text, async (markdown) => { - if (isServer) return fallback(markdown) + if (isServer) return "" const hash = checksum(markdown) const key = local.cacheKey ?? hash @@ -268,7 +255,7 @@ export function Markdown( if (key && hash) touch(key, { hash, html: safe }) return safe }, - { initialValue: isServer ? fallback(local.text) : "" }, + { initialValue: "" }, ) let copySetupTimer: ReturnType | undefined diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 8fc7090133..6ef61cbb79 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -27,6 +27,14 @@ color: var(--text-weak); } + [data-slot="user-message-inner"] { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-end; + width: 100%; + gap: 4px; + } [data-slot="user-message-attachments"] { display: flex; flex-wrap: wrap; @@ -35,6 +43,7 @@ width: fit-content; max-width: min(82%, 64ch); margin-left: auto; + margin-bottom: 4px; } [data-slot="user-message-attachment"] { @@ -134,7 +143,7 @@ [data-slot="user-message-copy-wrapper"] { min-height: 24px; - margin-top: 4px; + margin-top: 0; display: flex; align-items: center; justify-content: flex-end; @@ -144,7 +153,6 @@ pointer-events: none; transition: opacity 0.15s ease; will-change: opacity; - [data-component="tooltip-trigger"] { display: inline-flex; width: fit-content; @@ -187,56 +195,21 @@ opacity: 1; pointer-events: auto; } - - .text-text-strong { - color: var(--text-strong); - } - - .font-medium { - font-weight: var(--font-weight-medium); - } } [data-component="text-part"] { width: 100%; - margin-top: 24px; + margin-top: 0; + padding-bottom: 8px; + position: relative; [data-slot="text-part-body"] { margin-top: 0; } - [data-slot="text-part-copy-wrapper"] { - min-height: 24px; - margin-top: 4px; - display: flex; - align-items: center; - justify-content: flex-start; - gap: 10px; - opacity: 0; - pointer-events: none; - transition: opacity 0.15s ease; - will-change: opacity; - - [data-component="tooltip-trigger"] { - display: inline-flex; - width: fit-content; - } - } - - [data-slot="text-part-meta"] { - user-select: none; - } - - [data-slot="text-part-copy-wrapper"][data-interrupted] { + [data-slot="text-part-turn-summary"] { width: 100%; - justify-content: flex-end; - gap: 12px; - } - - &:hover [data-slot="text-part-copy-wrapper"], - &:focus-within [data-slot="text-part-copy-wrapper"] { - opacity: 1; - pointer-events: auto; + min-width: 0; } [data-component="markdown"] { @@ -278,7 +251,6 @@ line-height: var(--line-height-normal); [data-component="markdown"] { - margin-top: 24px; font-style: normal; font-size: inherit; color: var(--text-weak); @@ -372,13 +344,16 @@ height: auto; max-height: 240px; overflow-y: auto; + overscroll-behavior: contain; scrollbar-width: none; -ms-overflow-style: none; - + -webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); + mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; &::-webkit-scrollbar { display: none; } - [data-component="markdown"] { overflow: visible; } @@ -448,7 +423,7 @@ [data-component="write-trigger"] { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 8px; width: 100%; @@ -461,7 +436,8 @@ } [data-slot="message-part-title"] { - flex-shrink: 0; + flex-shrink: 1; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -493,40 +469,45 @@ [data-slot="message-part-title-text"] { text-transform: capitalize; color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="message-part-meta-line"], + .message-part-meta-line { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 6px; + font-weight: var(--font-weight-regular); + + [data-component="diff-changes"] { + flex-shrink: 0; + gap: 6px; + } + } + + .message-part-meta-line.soft { + [data-slot="message-part-title-filename"] { + color: var(--text-base); + } } [data-slot="message-part-title-filename"] { /* No text-transform - preserve original filename casing */ - font-weight: var(--font-weight-regular); + color: var(--text-strong); + flex-shrink: 0; } - [data-slot="message-part-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - font-weight: var(--font-weight-regular); - } - - [data-slot="message-part-directory"] { + [data-slot="message-part-directory-inline"] { color: var(--text-weak); + min-width: 0; + max-width: min(48vw, 36ch); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; direction: rtl; text-align: left; } - - [data-slot="message-part-filename"] { - color: var(--text-strong); - flex-shrink: 0; - } - - [data-slot="message-part-actions"] { - display: flex; - gap: 16px; - align-items: center; - justify-content: flex-end; - } } [data-component="edit-content"] { @@ -617,6 +598,17 @@ } } +[data-slot="webfetch-meta"] { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 8px; + + [data-component="tool-action"] { + flex-shrink: 0; + } +} + [data-component="todos"] { padding: 10px 0 24px 0; display: flex; @@ -639,7 +631,6 @@ } [data-component="context-tool-group-trigger"] { - width: 100%; min-height: 24px; display: flex; align-items: center; @@ -647,28 +638,237 @@ gap: 0px; cursor: pointer; + &[data-pending] { + cursor: default; + } + [data-slot="context-tool-group-title"] { flex-shrink: 1; min-width: 0; } +} - [data-slot="collapsible-arrow"] { - color: var(--icon-weaker); +/* Prevent the trigger content from stretching full-width so the arrow sits after the text */ +[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) { + width: auto; + flex: 0 1 auto; + + [data-slot="basic-tool-tool-info"] { + flex: 0 1 auto; } } -[data-component="context-tool-group-list"] { - padding: 6px 0 4px 0; +[data-component="context-tool-step"] { + width: 100%; + min-width: 0; + padding-left: 12px; +} + +[data-component="context-tool-expanded-list"] { display: flex; flex-direction: column; - gap: 2px; + padding: 4px 0 4px 12px; + max-height: 200px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + -ms-overflow-style: none; + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; - [data-slot="context-tool-group-item"] { - min-width: 0; - padding: 6px 0; + &::-webkit-scrollbar { + display: none; } } +[data-component="context-tool-expanded-row"] { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + height: 22px; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + + [data-slot="context-tool-expanded-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-expanded-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-base); + opacity: 0.75; + } +} + +[data-component="context-tool-rolling-row"] { + display: inline-flex; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + white-space: nowrap; + overflow: hidden; + padding-left: 12px; + + [data-slot="context-tool-rolling-action"] { + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: 500; + color: var(--text-base); + } + + [data-slot="context-tool-rolling-detail"] { + flex-shrink: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--font-size-base); + color: var(--text-weak); + } +} + +[data-component="shell-rolling-results"] { + width: 100%; + min-width: 0; + display: flex; + flex-direction: column; + + [data-slot="shell-rolling-header-clip"] { + &:hover [data-slot="shell-rolling-actions"] { + opacity: 1; + } + + &[data-clickable="true"] { + cursor: pointer; + } + } + + [data-slot="shell-rolling-header"] { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: 100%; + height: 37px; + box-sizing: border-box; + } + + [data-slot="shell-rolling-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + } + + [data-slot="shell-rolling-subtitle"] { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-family-sans); + font-size: 14px; + font-weight: var(--font-weight-normal); + line-height: var(--line-height-large); + color: var(--text-weak); + } + + [data-slot="shell-rolling-actions"] { + flex-shrink: 0; + display: inline-flex; + align-items: center; + gap: 2px; + opacity: 0; + transition: opacity 0.15s ease; + } + + + .shell-rolling-copy { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + + [data-slot="icon-svg"] { + color: var(--icon-weaker); + } + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--text-base) 8%, transparent) !important; + box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important; + border-radius: var(--radius-sm); + + [data-slot="icon-svg"] { + color: var(--icon-base); + } + } + } + + [data-slot="shell-rolling-arrow"] { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--icon-weaker); + transform: rotate(-90deg); + transition: transform 0.15s ease; + } + + [data-slot="shell-rolling-arrow"][data-open="true"] { + transform: rotate(0deg); + } +} + +[data-component="shell-rolling-output"] { + width: 100%; + min-width: 0; +} + +[data-component="shell-rolling-command"], +[data-component="shell-rolling-row"] { + display: inline-flex; + align-items: center; + width: 100%; + min-width: 0; + overflow: hidden; + white-space: pre; + padding-left: 12px; +} + +[data-slot="shell-rolling-text"] { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + font-family: var(--font-family-mono); + font-feature-settings: var(--font-family-mono--font-feature-settings); + font-size: var(--font-size-small); + line-height: var(--line-height-large); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] { + color: var(--text-base); +} + +[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] { + color: var(--text-weaker); +} + +[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] { + color: var(--text-weak); +} + [data-component="diagnostics"] { display: flex; flex-direction: column; @@ -729,6 +929,30 @@ width: 100%; } +[data-slot="assistant-part-grow"] { + width: 100%; + min-width: 0; + overflow: visible; +} + +[data-component="tool-part-wrapper"][data-tool="bash"] { + [data-component="tool-trigger"] { + width: auto; + max-width: 100%; + } + + [data-slot="basic-tool-tool-info-main"] { + align-items: center; + } + + [data-slot="basic-tool-tool-title"], + [data-slot="basic-tool-tool-subtitle"] { + display: inline-flex; + align-items: center; + line-height: var(--line-height-large); + } +} + [data-component="dock-prompt"][data-kind="permission"] { position: relative; display: flex; @@ -1187,8 +1411,7 @@ position: sticky; top: var(--sticky-accordion-top, 0px); z-index: 20; - height: 40px; - padding-bottom: 8px; + height: 37px; background-color: var(--background-stronger); } } @@ -1199,11 +1422,12 @@ } [data-slot="apply-patch-trigger-content"] { - display: flex; + display: inline-flex; align-items: center; - justify-content: space-between; - width: 100%; - gap: 20px; + justify-content: flex-start; + max-width: 100%; + min-width: 0; + gap: 8px; } [data-slot="apply-patch-file-info"] { @@ -1237,9 +1461,9 @@ [data-slot="apply-patch-trigger-actions"] { flex-shrink: 0; display: flex; - gap: 16px; + gap: 8px; align-items: center; - justify-content: flex-end; + justify-content: flex-start; } [data-slot="apply-patch-change"] { @@ -1279,10 +1503,11 @@ } [data-component="tool-loaded-file"] { + min-width: 0; display: flex; align-items: center; gap: 8px; - padding: 4px 0 4px 28px; + padding: 4px 0 4px 12px; font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-regular); @@ -1293,4 +1518,11 @@ flex-shrink: 0; color: var(--icon-weak); } + + span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 9286d2a92a..8d84206ed3 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -5,14 +5,13 @@ import { createSignal, For, Match, - onMount, + on, Show, Switch, - onCleanup, - Index, type JSX, } from "solid-js" import stripAnsi from "strip-ansi" +import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -32,12 +31,10 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { useI18n } from "../context/i18n" -import { BasicTool } from "./basic-tool" -import { GenericTool } from "./basic-tool" +import { GenericTool, ToolCall } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" -import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -49,43 +46,14 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { AnimatedCountList } from "./tool-count-summary" -import { ToolStatusTitle } from "./tool-status-title" -import { animate } from "motion" -import { useLocation } from "@solidjs/router" - -function ShellSubmessage(props: { text: string; animate?: boolean }) { - let widthRef: HTMLSpanElement | undefined - let valueRef: HTMLSpanElement | undefined - - onMount(() => { - if (!props.animate) return - requestAnimationFrame(() => { - if (widthRef) { - animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) - } - if (valueRef) { - animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) - } - }) - }) - - return ( - - - - - {props.text} - - - - - ) -} +import { list } from "./text-utils" +import { GrowBox } from "./grow-box" +import { + COLLAPSIBLE_SPRING, +} from "./motion" +import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" +import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" +import { ShellRollingResults } from "./shell-rolling-results" interface Diagnostic { range: { @@ -126,64 +94,22 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } -export interface MessageProps { - message: MessageType - parts: PartType[] - showAssistantCopyPartID?: string | null - interrupted?: boolean - queued?: boolean - showReasoningSummaries?: boolean -} - export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean defaultOpen?: boolean showAssistantCopyPartID?: string | null - turnDurationMs?: number + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element + animate?: boolean + working?: boolean } export type PartComponent = Component export const PART_MAPPING: Record = {} -const TEXT_RENDER_THROTTLE_MS = 100 - -function createThrottledValue(getValue: () => string) { - const [value, setValue] = createSignal(getValue()) - let timeout: ReturnType | undefined - let last = 0 - - createEffect(() => { - const next = getValue() - const now = Date.now() - - const remaining = TEXT_RENDER_THROTTLE_MS - (now - last) - if (remaining <= 0) { - if (timeout) { - clearTimeout(timeout) - timeout = undefined - } - last = now - setValue(next) - return - } - if (timeout) clearTimeout(timeout) - timeout = setTimeout(() => { - last = Date.now() - setValue(next) - timeout = undefined - }, remaining) - }) - - onCleanup(() => { - if (timeout) clearTimeout(timeout) - }) - - return value -} - function relativizeProjectPath(path: string, directory?: string) { if (!path) return "" if (!directory) return path @@ -258,7 +184,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "task": return { icon: "task", - title: i18n.t("ui.tool.agent", { type: input.subagent_type || "task" }), + title: i18n.t("ui.tool.agent"), subtitle: input.description, } case "bash": @@ -305,7 +231,8 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: input.name || "skill", + title: i18n.t("ui.tool.skill"), + subtitle: typeof input.name === "string" ? input.name : undefined, } default: return { @@ -330,105 +257,36 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -function list(value: T[] | undefined | null, fallback: T[]) { - if (Array.isArray(value)) return value - return fallback -} +import { pageVisible } from "../hooks/use-page-visible" -function same(a: readonly T[] | undefined, b: readonly T[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - -type PartRef = { - messageID: string - partID: string -} - -type PartGroup = - | { - key: string - type: "part" - ref: PartRef - } - | { - key: string - type: "context" - refs: PartRef[] - } - -function sameRef(a: PartRef, b: PartRef) { - return a.messageID === b.messageID && a.partID === b.partID -} - -function sameGroup(a: PartGroup, b: PartGroup) { - if (a === b) return true - if (a.key !== b.key) return false - if (a.type !== b.type) return false - if (a.type === "part") { - if (b.type !== "part") return false - return sameRef(a.ref, b.ref) +function createGroupOpenState() { + const [state, setState] = createStore>({}) + const read = (key?: string, collapse?: boolean) => { + if (!key) return true + const value = state[key] + if (value !== undefined) return value + return !collapse } - if (b.type !== "context") return false - if (a.refs.length !== b.refs.length) return false - return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) -} - -function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((item, i) => sameGroup(item, b[i]!)) -} - -function groupParts(parts: { messageID: string; part: PartType }[]) { - const result: PartGroup[] = [] - let start = -1 - - const flush = (end: number) => { - if (start < 0) return - const first = parts[start] - const last = parts[end] - if (!first || !last) { - start = -1 - return - } - result.push({ - key: `context:${first.part.id}`, - type: "context", - refs: parts.slice(start, end + 1).map((item) => ({ - messageID: item.messageID, - partID: item.part.id, - })), - }) - start = -1 + const controlled = (key?: string) => { + if (!key) return false + return state[key] !== undefined } - - parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { - if (start < 0) start = index - return - } - - flush(index - 1) - result.push({ - key: `part:${item.messageID}:${item.part.id}`, - type: "part", - ref: { - messageID: item.messageID, - partID: item.part.id, - }, - }) - }) - - flush(parts.length - 1) - return result + const write = (key: string, value: boolean) => { + setState(key, value) + } + return { read, controlled, write } } -function partByID(parts: readonly PartType[], partID: string) { - return parts.find((part) => part.id === partID) +function shouldCollapseGroup( + statuses: (string | undefined)[], + opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean }, +) { + if (opts.afterTool) return true + if (opts.groupTail === false) return true + if (!pageVisible()) return false + if (opts.working) return false + if (!statuses.length) return false + return !statuses.some((s) => busy(s)) } function renderable(part: PartType, showReasoningSummaries = true) { @@ -444,7 +302,8 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "edit" || tool === "write") return edit + if (tool === "apply_patch") return false } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -452,98 +311,305 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { return toolDefaultOpen(part.tool, shell, edit) } +function PartGrow(props: { + children: JSX.Element + animate?: boolean + animateToggle?: boolean + gap?: number + fade?: boolean + edge?: boolean + edgeHeight?: number + edgeOpacity?: number + edgeIdle?: number + edgeFade?: number + edgeRise?: number + grow?: boolean + watch?: boolean + open?: boolean + spring?: import("./motion").SpringConfig + toggleSpring?: import("./motion").SpringConfig +}) { + return ( + + {props.children} + + ) +} + export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null - turnDurationMs?: number + showTurnDiffSummary?: boolean + turnDiffSummary?: () => JSX.Element working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean + animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const emptyTools: ToolPart[] = [] + const groupState = createGroupOpenState() + const grouped = createMemo(() => { + const keys: string[] = [] + const items: Record< + string, + | { + type: "part" + part: PartType + message: AssistantMessage + context?: boolean + groupKey?: string + afterTool?: boolean + groupTail?: boolean + groupParts?: { part: ToolPart; message: AssistantMessage }[] + } + | { + type: "context" + groupKey: string + parts: { part: ToolPart; message: AssistantMessage }[] + tail: boolean + afterTool: boolean + } + > = {} + const push = (key: string, item: (typeof items)[string]) => { + keys.push(key) + items[key] = item + } + const id = (part: PartType) => { + if (part.type === "tool") return part.callID || part.id + return part.id + } + const parts = props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ message, part })), + ) - const grouped = createMemo( - () => - groupParts( - props.messages.flatMap((message) => - list(data.store.part?.[message.id], emptyParts) - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: message.id, - part, - })), - ), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) + let start = -1 - const last = createMemo(() => grouped().at(-1)?.key) + const flush = (end: number, tail: boolean, afterTool: boolean) => { + if (start < 0) return + const group = parts + .slice(start, end + 1) + .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) + if (!group.length) { + start = -1 + return + } + const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` + push(groupKey, { + type: "context", + groupKey, + parts: group, + tail, + afterTool, + }) + group.forEach((entry) => { + push(`part:${entry.message.id}:${id(entry.part)}`, { + type: "part", + part: entry.part, + message: entry.message, + context: true, + groupKey, + afterTool, + groupTail: tail, + groupParts: group, + }) + }) + start = -1 + } + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1, false, (item as { part: PartType }).part.type === "tool") + push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) + }) + + flush(parts.length - 1, true, false) + return { keys, items } + }) + + const last = createMemo(() => grouped().keys.at(-1)) return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) + + {(key, idx) => { + const item = createMemo(() => grouped().items[key]) + const ctx = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "context") return + return value + }) + const part = createMemo(() => { + const value = item() + if (!value) return + if (value.type !== "part") return + return value + }) + const tail = createMemo(() => last() === key) + const tool = createMemo(() => { + const value = part() + if (!value) return false + return value.part.type === "tool" + }) + const context = createMemo(() => !!part()?.context) + const contextSpring = createMemo(() => { + const entry = part() + if (!entry?.context) return undefined + if (!groupState.controlled(entry.groupKey)) return undefined + return COLLAPSIBLE_SPRING + }) + const contextOpen = createMemo(() => { + const collapse = ( + afterTool?: boolean, + groupTail?: boolean, + group?: { part: ToolPart; message: AssistantMessage }[], + ) => + shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], { + afterTool, + groupTail, + working: props.working, + }) + const value = ctx() + if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts)) + const entry = part() + return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts)) + }) + const visible = createMemo(() => { + if (!context()) return true + // The context group header is always visible (it has its own expand arrow). + if (ctx()) return true + // Individual context parts are rendered inside the header's collapsible content, + // so they're always hidden at this level. + return false + }) + const turnSummary = createMemo(() => { + const value = part() + if (!value) return false + if (value.part.type !== "text") return false + if (!props.showTurnDiffSummary) return false + return props.showAssistantCopyPartID === value.part.id + }) + const fade = createMemo(() => { + if (ctx()) return true + return tool() + }) + const edge = createMemo(() => { + const entry = part() + if (!entry) return false + if (entry.part.type !== "text") return false + if (!props.working) return false + return tail() + }) + const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) + const ctxPartsCache = new Map() + let ctxPartsPrev: ToolPart[] = [] + const ctxParts = createMemo(() => { + const parts = ctx()?.parts ?? [] + // Guard against transient empty flash during store recomputation + if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev + const result: ToolPart[] = [] + for (const item of parts) { + const k = item.part.callID || item.part.id + const cached = ctxPartsCache.get(k) + if (cached) { + result.push(cached) + } else { + ctxPartsCache.set(k, item.part) + result.push(item.part) + } + } + ctxPartsPrev = result + return result + }) + const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) + const ctxPending = ctxPendingRaw + const ctxHoldOpen = hold(ctxPendingRaw) + const shell = createMemo(() => { + const value = part() + if (!value) return + if (value.part.type !== "tool") return + if (value.part.tool !== "bash") return + return value.part + }) return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() - if (entry.type !== "context") return emptyTools - return entry.refs - .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - const busy = createMemo(() => props.working && last() === entryAccessor().key) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const message = createMemo(() => { - const entry = entryAccessor() - if (entry.type !== "part") return - return props.messages.find((item) => item.id === entry.ref.messageID) - }) - const part = createMemo(() => { - const entry = entryAccessor() - if (entry.type !== "part") return - return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) - }) - - return ( - - - - - - ) - })()} - - + <> + + + {(entry) => ( + groupState.write(entry().groupKey, value)} + /> + )} + + + {(entry) => ( +
+ +
+ )} +
+
+ + + + + {(value) => } + ) }} -
+ ) } @@ -551,76 +617,6 @@ function isContextGroupTool(part: PartType): part is ToolPart { return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) } -function contextToolDetail(part: ToolPart): string | undefined { - const info = getToolInfo(part.tool, part.state.input ?? {}) - if (info.subtitle) return info.subtitle - if (part.state.status === "error") return part.state.error - if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) - return part.state.title - const description = part.state.input?.description - if (typeof description === "string") return description - return undefined -} - -function contextToolTrigger(part: ToolPart, i18n: ReturnType) { - const input = (part.state.input ?? {}) as Record - const path = typeof input.path === "string" ? input.path : "/" - const filePath = typeof input.filePath === "string" ? input.filePath : undefined - const pattern = typeof input.pattern === "string" ? input.pattern : undefined - const include = typeof input.include === "string" ? input.include : undefined - const offset = typeof input.offset === "number" ? input.offset : undefined - const limit = typeof input.limit === "number" ? input.limit : undefined - - switch (part.tool) { - case "read": { - const args: string[] = [] - if (offset !== undefined) args.push("offset=" + offset) - if (limit !== undefined) args.push("limit=" + limit) - return { - title: i18n.t("ui.tool.read"), - subtitle: filePath ? getFilename(filePath) : "", - args, - } - } - case "list": - return { - title: i18n.t("ui.tool.list"), - subtitle: getDirectory(path), - } - case "glob": - return { - title: i18n.t("ui.tool.glob"), - subtitle: getDirectory(path), - args: pattern ? ["pattern=" + pattern] : [], - } - case "grep": { - const args: string[] = [] - if (pattern) args.push("pattern=" + pattern) - if (include) args.push("include=" + include) - return { - title: i18n.t("ui.tool.grep"), - subtitle: getDirectory(path), - args, - } - } - default: { - const info = getToolInfo(part.tool, input) - return { - title: info.title, - subtitle: info.subtitle || contextToolDetail(part), - args: [], - } - } - } -} - -function contextToolSummary(parts: ToolPart[]) { - const read = parts.filter((part) => part.tool === "read").length - const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length - const list = parts.filter((part) => part.tool === "list").length - return { read, search, list } -} - function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -651,210 +647,12 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } -export function Message(props: MessageProps) { - return ( - - - {(userMessage) => ( - - )} - - - {(assistantMessage) => ( - - )} - - - ) -} - -export function AssistantMessageDisplay(props: { - message: AssistantMessage - parts: PartType[] - showAssistantCopyPartID?: string | null - showReasoningSummaries?: boolean -}) { - const emptyTools: ToolPart[] = [] - const grouped = createMemo( - () => - groupParts( - props.parts - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ - messageID: props.message.id, - part, - })), - ), - [] as PartGroup[], - { equals: sameGroups }, - ) - - return ( - - {(entryAccessor) => { - const entryType = createMemo(() => entryAccessor().type) - - return ( - - - {(() => { - const parts = createMemo( - () => { - const entry = entryAccessor() - if (entry.type !== "context") return emptyTools - return entry.refs - .map((ref) => partByID(props.parts, ref.partID)) - .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) - }, - emptyTools, - { equals: same }, - ) - - return ( - 0}> - - - ) - })()} - - - {(() => { - const part = createMemo(() => { - const entry = entryAccessor() - if (entry.type !== "part") return - return partByID(props.parts, entry.ref.partID) - }) - - return ( - - - - ) - })()} - - - ) - }} - - ) -} - -function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { - const i18n = useI18n() - const [open, setOpen] = createSignal(false) - const pending = createMemo( - () => - !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), - ) - const summary = createMemo(() => contextToolSummary(props.parts)) - - return ( - - -
- - - - - - - - - -
-
- -
- - {(partAccessor) => { - const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) - const running = createMemo( - () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", - ) - return ( -
-
-
-
-
-
- - - - - {trigger().subtitle} - - - - {(arg) => {arg}} - - -
-
-
-
-
-
- ) - }} -
-
-
-
- ) -} export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean + animate?: boolean queued?: boolean }) { const data = useData() @@ -904,14 +702,9 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const metaHead = createMemo(() => { + const userMeta = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] - return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") - }) - - const metaTail = createMemo(() => { - const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -928,93 +721,83 @@ export function UserMessageDisplay(props: { } return ( -
- 0}> -
- - {(file) => ( -
{ - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - - -
- } - > - {file.filename - -
- )} - -
- - - <> -
-
- + +
+
+ 0}> +
+ + {(file) => ( +
{ + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + + +
+ } + > + {file.filename + +
+ )} +
- -
- + + + <> +
+
+ +
+ +
+ +
+
-
-
-
- - - +
+ - {metaHead()} + {userMeta()} - - - {"\u00A0\u00B7\u00A0"} - - - - - {metaTail()} - - - - - - e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - -
- -
-
+ + e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + +
+ + +
+
+ ) } @@ -1068,7 +851,10 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} - turnDurationMs={props.turnDurationMs} + showTurnDiffSummary={props.showTurnDiffSummary} + turnDiffSummary={props.turnDiffSummary} + animate={props.animate} + working={props.working} />
) @@ -1078,12 +864,16 @@ export interface ToolProps { input: Record metadata: Record tool: string + partID?: string + callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean + animate?: boolean + reveal?: boolean } export type ToolComponent = Component @@ -1117,7 +907,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre @@ -1148,30 +938,26 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = () => props.part as ToolPart - if (part().tool === "todowrite" || part().tool === "todoread") return null - - const hideQuestion = createMemo( - () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), - ) + const part = props.part as ToolPart + const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) const emptyInput: Record = {} const emptyMetadata: Record = {} - const input = () => part().state?.input ?? emptyInput + const input = () => part.state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part().state?.metadata ?? emptyMetadata + const partMetadata = () => part.state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) + const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) return ( -
+
- + {(error) => { const cleaned = error().replace("Error: ", "") - if (part().tool === "question" && cleaned.includes("dismissed this question")) { + if (part.tool === "question" && cleaned.includes("dismissed this question")) { return (
@@ -1205,13 +991,17 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { @@ -1236,74 +1026,16 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { } PART_MAPPING["text"] = function TextPartDisplay(props) { - const data = useData() - const i18n = useI18n() const part = () => props.part as TextPart - const interrupted = createMemo( - () => - props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", - ) - - const model = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const match = data.store.provider?.all?.find((p) => p.id === message.providerID) - return match?.models?.[message.modelID]?.name ?? message.modelID - }) - - const duration = createMemo(() => { - if (props.message.role !== "assistant") return "" - const message = props.message as AssistantMessage - const completed = message.time.completed - const ms = - typeof props.turnDurationMs === "number" - ? props.turnDurationMs - : typeof completed === "number" - ? completed - message.time.created - : -1 - if (!(ms >= 0)) return "" - const total = Math.round(ms / 1000) - if (total < 60) return `${total}s` - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return `${minutes}m ${seconds}s` - }) - - const meta = createMemo(() => { - if (props.message.role !== "assistant") return "" - const agent = (props.message as AssistantMessage).agent - const items = [ - agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", - model(), - duration(), - interrupted() ? i18n.t("ui.message.interrupted") : "", - ] - return items.filter((x) => !!x).join(" \u00B7 ") - }) const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const isLastTextPart = createMemo(() => { - const last = (data.store.part?.[props.message.id] ?? []) - .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) - .at(-1) - return last?.id === part().id + const summary = createMemo(() => { + if (props.message.role !== "assistant") return + if (!props.showTurnDiffSummary) return + if (props.showAssistantCopyPartID !== part().id) return + return props.turnDiffSummary }) - const showCopy = createMemo(() => { - if (props.message.role !== "assistant") return isLastTextPart() - if (props.showAssistantCopyPartID === null) return false - if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id - return isLastTextPart() - }) - const [copied, setCopied] = createSignal(false) - - const handleCopy = async () => { - const content = displayText() - if (!content) return - await navigator.clipboard.writeText(content) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } return ( @@ -1311,28 +1043,12 @@ PART_MAPPING["text"] = function TextPartDisplay(props) {
- -
- - e.preventDefault()} - onClick={handleCopy} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} - /> - - - - {meta()} - - -
+ + {(render) => ( + +
{render()()}
+
+ )}
@@ -1362,30 +1078,33 @@ ToolRegistry.register({ if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) const loaded = createMemo(() => { - if (props.status !== "completed") return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) + const pending = createMemo(() => busy(props.status)) return ( <> - + } /> {(filepath) => ( -
- - - {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} - -
+ )}
@@ -1397,18 +1116,29 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - + } > -
- -
+ {(output) => ( +
+ +
+ )}
-
+ ) }, }) @@ -1417,22 +1147,30 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) return ( - - -
+ {(output) => ( +
+ +
+ )} - + ) }, }) @@ -1444,40 +1182,214 @@ ToolRegistry.register({ const args: string[] = [] if (props.input.pattern) args.push("pattern=" + props.input.pattern) if (props.input.include) args.push("include=" + props.input.include) + const pending = createMemo(() => busy(props.status)) return ( - + } > -
- -
+ {(output) => ( +
+ +
+ )}
-
+ ) }, }) +function useToolReveal(pending: () => boolean, animate?: () => boolean) { + const enabled = () => animate?.() ?? true + const [live, setLive] = createSignal(pending() || enabled()) + createEffect(() => { + if (pending()) setLive(true) + }) + return () => enabled() && live() +} + +function WebfetchMeta(props: { url: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + event.stopPropagation()} + > + {props.url} + +
+ +
+
+ ) +} + +function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { + let ref: HTMLAnchorElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolText(props: { text: string; delay?: number; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) + + return ( + + {props.text} + + ) +} + +function ToolLoadedFile(props: { text: string; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) + + return ( + +
+ + {props.text} +
+
+ ) +} + +function ToolTriggerRow(props: { + title: string + pending: boolean + subtitle?: string + args?: string[] + action?: JSX.Element + animate?: boolean + revealOnMount?: boolean +}) { + const reveal = useToolReveal( + () => props.pending, + () => props.animate !== false, + ) + const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) + const detailAnimate = createMemo(() => { + if (props.animate === false) return false + if (props.revealOnMount) return true + if (!props.pending && !reveal()) return true + return reveal() + }) + + return ( +
+
+ + + + {(text) => } +
+ {props.action} +
+ ) +} + +type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] + +function ToolMetaLine(props: { + filename: string + path?: string + changes?: DiffValue + delay?: number + animate?: boolean + soft?: boolean +}) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) + + return ( + + {props.filename} + + {props.path} + + {(changes) => } + + ) +} + +function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { + let ref: HTMLDivElement | undefined + useToolFade(() => ref, { delay: 0.04, animate: props.animate }) + + return ( +
+ +
+ ) +} + +function ShellText(props: { text: string; animate?: boolean }) { + let ref: HTMLSpanElement | undefined + useToolFade(() => ref, { wipe: true, animate: props.animate }) + + return ( + + + + {props.text} + + + + ) +} + ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" return value }) return ( - @@ -1485,24 +1397,8 @@ ToolRegistry.register({ - - event.stopPropagation()} - > - {url()} - - + {(value) => }
- -
- -
-
} /> @@ -1521,7 +1417,8 @@ ToolRegistry.register({ }) return ( - - +
) }, }) @@ -1547,7 +1444,8 @@ ToolRegistry.register({ }) return ( - - + ) }, }) @@ -1567,15 +1465,19 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() - const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined - const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) + const agentType = createMemo(() => { + const raw = props.input.subagent_type + if (typeof raw !== "string" || !raw) return undefined + return raw[0]!.toUpperCase() + raw.slice(1) + }) const description = createMemo(() => { const value = props.input.description if (typeof value === "string") return value return undefined }) - const running = createMemo(() => props.status === "pending" || props.status === "running") + const running = createMemo(() => busy(props.status)) + const reveal = useToolReveal(running, () => props.reveal !== false) const href = createMemo(() => { const sessionId = childSessionId() @@ -1584,34 +1486,50 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - const path = location.pathname + if (typeof window === "undefined") return + const path = window.location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}` }) - const titleContent = () => + const handleLinkClick = (e: MouseEvent) => { + const sessionId = childSessionId() + const url = href() + if (!sessionId || !url) return + + e.stopPropagation() + + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const nav = data.navigateToSession + if (!nav || typeof window === "undefined") return + + e.preventDefault() + const before = window.location.pathname + window.location.search + window.location.hash + nav(sessionId) + setTimeout(() => { + const after = window.location.pathname + window.location.search + window.location.hash + if (after === before) window.location.assign(url) + }, 50) + } const trigger = () => (
- - {titleContent()} + + + {(type) => } - e.stopPropagation()} - > - {description()} - + {(url) => ( + + )} - {description()} + @@ -1619,7 +1537,7 @@ ToolRegistry.register({
) - return + return }, }) @@ -1627,13 +1545,26 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - const pending = () => props.status === "pending" || props.status === "running" - const sawPending = pending() - const text = createMemo(() => { - const cmd = props.input.command ?? props.metadata.command ?? "" - const out = stripAnsi(props.output || props.metadata.output || "") - return `$ ${cmd}${out ? "\n\n" + out : ""}` + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) + const subtitle = () => props.input.description ?? props.metadata.description + const cmd = createMemo(() => { + const value = props.input.command ?? props.metadata.command + if (typeof value === "string") return value + return "" }) + const output = createMemo(() => { + if (typeof props.output === "string") return props.output + if (typeof props.metadata.output === "string") return props.metadata.output + return "" + }) + const command = createMemo(() => `$ ${cmd()}`) + const result = createMemo(() => stripAnsi(output())) + const text = createMemo(() => { + const value = result() + return `${command()}${value ? "\n\n" + value : ""}` + }) + const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1645,18 +1576,20 @@ ToolRegistry.register({ } return ( -
- - - + {(text) => }
} @@ -1684,7 +1617,7 @@ ToolRegistry.register({
- + ) }, }) @@ -1697,13 +1630,15 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
-
@@ -1711,20 +1646,17 @@ ToolRegistry.register({ - - {filename()} + + {(name) => ( + + )}
- -
- {getDirectory(props.input.filePath!)} -
-
-
-
- - -
} @@ -1734,7 +1666,7 @@ ToolRegistry.register({ path={path()} actions={ - + {(diff) => } } > @@ -1755,7 +1687,7 @@ ToolRegistry.register({ - + ) }, @@ -1769,13 +1701,15 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => busy(props.status) + const reveal = useToolReveal(pending, () => props.reveal !== false) return (
-
@@ -1783,17 +1717,17 @@ ToolRegistry.register({ - - {filename()} + + {(name) => ( + + )}
- -
- {getDirectory(props.input.filePath!)} -
-
-
{/* */}
} > @@ -1814,7 +1748,7 @@ ToolRegistry.register({ - + ) }, @@ -1838,7 +1772,8 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => props.status === "pending" || props.status === "running") + const pending = createMemo(() => busy(props.status)) + const reveal = useToolReveal(pending, () => props.reveal !== false) const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -1846,7 +1781,6 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal([]) let seeded = false - createEffect(() => { const list = files() if (list.length === 0) return @@ -1854,7 +1788,6 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) - const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -1862,24 +1795,44 @@ ToolRegistry.register({ }) return ( - - +
+ +
+
+ + + + + {(file) => ( + + )} + + {(text) => } +
+
+
+ } + > + 0}> setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1887,13 +1840,11 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) - createEffect(() => { if (!active()) { setVisible(false) return } - requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -1958,77 +1909,50 @@ ToolRegistry.register({ -
- - } - > -
- -
-
- - - - - {getFilename(single()!.relativePath)} - -
- -
- {getDirectory(single()!.relativePath)} -
-
-
-
- - - -
-
} > - - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.deleted")} - - - - - {i18n.t("ui.patch.action.moved")} - - - - - - - } - > -
- -
-
- - -
+ {(file) => ( + + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + } + > +
+ +
+
+ )} + + + ) }, }) @@ -2046,6 +1970,7 @@ ToolRegistry.register({ return [] }) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -2054,14 +1979,19 @@ ToolRegistry.register({ }) return ( - + } >
@@ -2079,7 +2009,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2091,6 +2021,7 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) + const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2100,14 +2031,19 @@ ToolRegistry.register({ }) return ( - + } >
@@ -2124,7 +2060,7 @@ ToolRegistry.register({
-
+ ) }, }) @@ -2132,21 +2068,28 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const title = createMemo(() => props.input.name || "skill") - const running = createMemo(() => props.status === "pending" || props.status === "running") - - const titleContent = () => - - const trigger = () => ( -
-
- - {titleContent()} - -
-
+ const i18n = useI18n() + const pending = createMemo(() => busy(props.status)) + const name = createMemo(() => { + const value = props.input.name || props.metadata.name + if (typeof value === "string") return value + }) + return ( + + } + animate + /> ) - - return }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index a5104a1a3e..5deefcfa61 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -1,8 +1,9 @@ import { attachSpring, motionValue } from "motion" import type { SpringOptions } from "motion" import { createEffect, createSignal, onCleanup } from "solid-js" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" -type Opt = Partial> +type Opt = Pick const eq = (a: Opt | undefined, b: Opt | undefined) => a?.visualDuration === b?.visualDuration && a?.bounce === b?.bounce && @@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) + const reduce = prefersReducedMotion const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let stop = attachSpring(spring, source, config) - let off = spring.on("change", (next: number) => setValue(next)) + let reduced = reduce() + let stop = reduced ? () => {} : attachSpring(spring, source, config) + let off = spring.on("change", (next) => setValue(next)) createEffect(() => { - source.set(target()) + const next = target() + if (reduced) { + source.set(next) + spring.set(next) + setValue(next) + return + } + source.set(next) }) createEffect(() => { - if (!options) return const next = read() - if (eq(config, next)) return + const skip = reduce() + if (eq(config, next) && reduced === skip) return config = next + reduced = skip stop() - stop = attachSpring(spring, source, next) + stop = skip ? () => {} : attachSpring(spring, source, next) + if (skip) { + const value = target() + source.set(value) + spring.set(value) + setValue(value) + return + } setValue(spring.get()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx new file mode 100644 index 0000000000..6cdf01c731 --- /dev/null +++ b/packages/ui/src/components/motion.tsx @@ -0,0 +1,77 @@ +import { followValue } from "motion" +import type { MotionValue } from "motion" + +export { animate, springValue } from "motion" +export type { AnimationPlaybackControls } from "motion" + +/** + * Like `springValue` but preserves getters on the config object. + * `springValue` spreads config at creation, snapshotting getter values. + * This passes the config through to `followValue` intact, so getters + * on `visualDuration` etc. fire on every `.set()` call. + */ +export function tunableSpringValue(initial: T, config: SpringConfig): MotionValue { + return followValue(initial, config as any) +} + +let _growDuration = 0.5 +let _collapsibleDuration = 0.3 + +export const GROW_SPRING = { + type: "spring" as const, + get visualDuration() { + return _growDuration + }, + bounce: 0, +} + +export const COLLAPSIBLE_SPRING = { + type: "spring" as const, + get visualDuration() { + return _collapsibleDuration + }, + bounce: 0, +} + +export const setGrowDuration = (v: number) => { + _growDuration = v +} +export const setCollapsibleDuration = (v: number) => { + _collapsibleDuration = v +} +export const getGrowDuration = () => _growDuration +export const getCollapsibleDuration = () => _collapsibleDuration + +export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number } + +export const FAST_SPRING = { + type: "spring" as const, + visualDuration: 0.35, + bounce: 0, +} + +export const GLOW_SPRING = { + type: "spring" as const, + visualDuration: 0.4, + bounce: 0.15, +} + +export const WIPE_MASK = + "linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)" + +export const clearMaskStyles = (el: HTMLElement) => { + el.style.maskImage = "" + el.style.webkitMaskImage = "" + el.style.maskSize = "" + el.style.webkitMaskSize = "" + el.style.maskRepeat = "" + el.style.webkitMaskRepeat = "" + el.style.maskPosition = "" + el.style.webkitMaskPosition = "" +} + +export const clearFadeStyles = (el: HTMLElement) => { + el.style.opacity = "" + el.style.filter = "" + el.style.transform = "" +} diff --git a/packages/ui/src/components/new-composer.stories.tsx b/packages/ui/src/components/new-composer.stories.tsx new file mode 100644 index 0000000000..1ae957fbfa --- /dev/null +++ b/packages/ui/src/components/new-composer.stories.tsx @@ -0,0 +1,451 @@ +// @ts-nocheck +import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { NewComposer } from "./new-composer" + +const docs = `### Overview +Runtime-ready composer story with visible canvas controls + Storybook controls. + +### Canvas controls +- Input / Question / Permission mode +- Todos, collapse, context, drag overlay +- Keyboard: Ctrl+1..7 toggles, Ctrl+8/9 answer count (Q1), Ctrl+[/] answer count (Q2), Ctrl+;/' answer count (Q3), Ctrl+0/- todo count + +### Storybook controls +- mode +- working +- accepting +- showTodos +- todoCollapsed +- forceDragType +- answerCount1 +- answerCount2 +- answerCount3 +- todoCount +` + +export default { + title: "UI/NewComposer", + id: "components-new-composer", + component: NewComposer, + tags: ["autodocs"], + parameters: { + docs: { + description: { + component: docs, + }, + }, + }, + argTypes: { + mode: { + control: "select", + options: ["input", "question", "permission"], + }, + working: { + control: "boolean", + }, + accepting: { + control: "boolean", + }, + showTodos: { + control: "boolean", + }, + todoCollapsed: { + control: "boolean", + }, + forceDragType: { + control: "select", + options: [null, "image", "@mention"], + }, + answerCount1: { + control: { type: "range", min: 1, max: 6, step: 1 }, + }, + answerCount2: { + control: { type: "range", min: 1, max: 6, step: 1 }, + }, + answerCount3: { + control: { type: "range", min: 1, max: 6, step: 1 }, + }, + todoCount: { + control: { type: "range", min: 0, max: 8, step: 1 }, + }, + }, +} + +const questions = [ + { + text: "Which editor do you use most often?", + options: [ + { label: "Neovim", description: "Fast keyboard-driven workflow" }, + { label: "VS Code", description: "Feature-rich and extensible" }, + { label: "Zed", description: "Lightweight modern editor" }, + { label: "JetBrains IDE", description: "Deep language tooling" }, + { label: "Sublime Text", description: "Fast, lightweight setup" }, + { label: "Helix", description: "Modal editor with tree-sitter" }, + ], + multiple: false, + }, + { + text: "Which testing frameworks should we add?", + options: [ + { label: "Vitest", description: "Fast unit testing" }, + { label: "Playwright", description: "E2E browser testing" }, + { label: "Testing Library", description: "Component testing" }, + { label: "Cypress", description: "Browser integration tests" }, + { label: "Jest", description: "Legacy compatibility" }, + { label: "Storybook Tests", description: "Interaction smoke tests" }, + ], + multiple: true, + }, + { + text: "How strict should linting be?", + options: [ + { label: "Minimal", description: "Keep only important rules" }, + { label: "Balanced", description: "Recommended defaults" }, + { label: "Strict", description: "Catch everything possible" }, + { label: "Very strict", description: "Treat warnings as errors" }, + { label: "Preset by package", description: "Different rules per surface" }, + { label: "Experimental", description: "Try strict mode for one sprint" }, + ], + multiple: false, + }, +] + +const todos = [ + { content: "Read auth module", status: "completed" }, + { content: "Refactor token logic", status: "in_progress" }, + { content: "Add tests", status: "pending" }, + { content: "Ship changes", status: "pending" }, + { content: "Check telemetry events", status: "pending" }, + { content: "Validate markdown rendering", status: "pending" }, + { content: "Run regression suite", status: "pending" }, + { content: "Update release notes", status: "pending" }, +] + +const ctx = [ + { id: "a", path: "src/auth.ts", selection: { startLine: 5, endLine: 10 }, comment: "Check token refresh" }, + { id: "b", path: "src/routes/login.ts", comment: "Review edge cases" }, +] + +const dragModes = [null, "image", "@mention"] as const + +const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +const panel = { + position: "fixed", + top: "14px", + left: "14px", + display: "flex", + "flex-wrap": "wrap", + gap: "8px", + padding: "8px", + "border-radius": "10px", + background: "rgba(0, 0, 0, 0.55)", + border: "1px solid rgba(255, 255, 255, 0.12)", + color: "#fff", + "font-family": "monospace", + "font-size": "11px", + "z-index": 50, +} as const + +const btn = (on: boolean) => + ({ + padding: "4px 8px", + "border-radius": "8px", + border: "1px solid rgba(255, 255, 255, 0.2)", + background: on ? "rgba(255, 255, 255, 0.2)" : "rgba(255, 255, 255, 0.07)", + color: "#fff", + cursor: "pointer", + }) as const + +export const Interactive = { + args: { + mode: "input", + working: false, + accepting: false, + showTodos: true, + todoCollapsed: false, + forceDragType: null, + answerCount1: 4, + answerCount2: 3, + answerCount3: 5, + todoCount: 4, + }, + render: (args) => { + const limit = (value: number, i: number) => Math.max(1, Math.min(questions[i].options.length, value)) + + const [mode, setMode] = createSignal(args.mode ?? "input") + const [tab, setTab] = createSignal(0) + const [work, setWork] = createSignal(!!args.working) + const [accept, setAccept] = createSignal(!!args.accepting) + const [showTodos, setShowTodos] = createSignal(args.showTodos ?? true) + const [todoCollapsed, setTodoCollapsed] = createSignal(args.todoCollapsed ?? false) + const [drag, setDrag] = createSignal(args.forceDragType ?? null) + const [a1, setA1] = createSignal(limit(args.answerCount1 ?? 4, 0)) + const [a2, setA2] = createSignal(limit(args.answerCount2 ?? 3, 1)) + const [a3, setA3] = createSignal(limit(args.answerCount3 ?? 5, 2)) + const [tCount, setTCount] = createSignal(Math.max(0, Math.min(todos.length, args.todoCount ?? 4))) + const [showCtx, setShowCtx] = createSignal(true) + const [agent, setAgent] = createSignal("ask") + const [model, setModel] = createSignal("OpenAI/GPT-5.3 Codex") + const [variant, setVariant] = createSignal("default") + const [history, setHistory] = createSignal<{ normal: string[]; shell: string[] }>({ normal: [], shell: [] }) + + createEffect(() => setMode(args.mode ?? "input")) + createEffect(() => setWork(!!args.working)) + createEffect(() => setAccept(!!args.accepting)) + createEffect(() => setShowTodos(args.showTodos ?? true)) + createEffect(() => setTodoCollapsed(args.todoCollapsed ?? false)) + createEffect(() => setDrag(args.forceDragType ?? null)) + createEffect(() => setA1(limit(args.answerCount1 ?? 4, 0))) + createEffect(() => setA2(limit(args.answerCount2 ?? 3, 1))) + createEffect(() => setA3(limit(args.answerCount3 ?? 5, 2))) + createEffect(() => setTCount(Math.max(0, Math.min(todos.length, args.todoCount ?? 4)))) + + const qList = createMemo(() => [ + { ...questions[0], options: questions[0].options.slice(0, a1()) }, + { ...questions[1], options: questions[1].options.slice(0, a2()) }, + { ...questions[2], options: questions[2].options.slice(0, a3()) }, + ]) + const tList = createMemo(() => todos.slice(0, tCount())) + + createEffect(() => { + if (tab() <= qList().length - 1) return + setTab(qList().length - 1) + }) + + const q = createMemo(() => qList()[tab()] ?? qList()[0]) + + const runtime = { + submit: async (input) => { + setWork(true) + await wait(550) + setWork(false) + console.log("submit", input) + }, + abort: () => { + setWork(false) + }, + toggleAccept: () => setAccept((v) => !v), + runSlash: (cmd) => { + if (cmd.type !== "builtin") return false + console.log("slash:run", cmd.id) + return true + }, + historyRead: (mode) => history()[mode], + historyWrite: (mode, list) => { + setHistory((prev) => ({ ...prev, [mode]: list })) + }, + decidePermission: async (response) => { + console.log("permission", response) + await wait(200) + setMode("input") + }, + submitQuestion: async (answers) => { + console.log("question", answers) + await wait(250) + setMode("input") + setTab(0) + }, + rejectQuestion: async () => { + await wait(150) + setMode("input") + setTab(0) + }, + openContext: (item) => console.log("open context", item), + removeContext: (item) => console.log("remove context", item), + } + + const cycle = () => { + const i = dragModes.findIndex((v) => v === drag()) + const n = (i + 1) % dragModes.length + setDrag(dragModes[n]) + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return + if (target instanceof HTMLElement && target.isContentEditable) return + + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return + + let hit = false + if (event.key === "1") { + setMode("input") + hit = true + } + if (event.key === "2") { + setMode("question") + hit = true + } + if (event.key === "3") { + setMode("permission") + hit = true + } + if (event.key === "4") { + setTodoCollapsed((v) => !v) + hit = true + } + if (event.key === "5") { + setShowTodos((v) => !v) + hit = true + } + if (event.key === "6") { + setShowCtx((v) => !v) + hit = true + } + if (event.key === "7") { + cycle() + hit = true + } + if (event.key === "8") { + setA1((v) => limit(v - 1, 0)) + hit = true + } + if (event.key === "9") { + setA1((v) => limit(v + 1, 0)) + hit = true + } + if (event.key === "[") { + setA2((v) => limit(v - 1, 1)) + hit = true + } + if (event.key === "]") { + setA2((v) => limit(v + 1, 1)) + hit = true + } + if (event.key === ";") { + setA3((v) => limit(v - 1, 2)) + hit = true + } + if (event.key === "'") { + setA3((v) => limit(v + 1, 2)) + hit = true + } + if (event.key === "0") { + setTCount((v) => Math.max(0, v - 1)) + hit = true + } + if (event.key === "-") { + setTCount((v) => Math.min(todos.length, v + 1)) + hit = true + } + + if (!hit) return + event.preventDefault() + event.stopPropagation() + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + return ( +
+
+ + + + + + + + + + + Q1:{a1()} + + + Q2:{a2()} + + + Q3:{a3()} + + + T:{tCount()} + +
+ + setTab((v) => Math.max(0, v - 1))} + onQuestionNext={() => setTab((v) => Math.min(qList().length - 1, v + 1))} + onQuestionJump={setTab} + onQuestionDismiss={() => { + setMode("input") + setTab(0) + }} + showTodos={showTodos()} + todoCollapsed={todoCollapsed()} + onTodoCollapseChange={setTodoCollapsed} + todos={tList()} + contextItems={showCtx() ? ctx : []} + forceDragType={drag()} + permissionDescription="This action needs write access to project files." + permissionPatterns={["src/**/*.ts", "tests/**/*.ts"]} + /> +
+ ) + }, +} diff --git a/packages/ui/src/components/new-composer.tsx b/packages/ui/src/components/new-composer.tsx new file mode 100644 index 0000000000..e5877506cf --- /dev/null +++ b/packages/ui/src/components/new-composer.tsx @@ -0,0 +1,1097 @@ +import { + Match, + Show, + Switch, + createEffect, + createMemo, + createSignal, + on, + onCleanup, + onMount, + type Component, +} from "solid-js" +import { useElementHeight } from "@opencode-ai/ui/hooks" +import { useI18n } from "../context/i18n" +import { ComposerDragOverlay } from "./new-composer/drag-overlay" +import { DEFAULT_AT_OPTIONS, DEFAULT_SLASH_COMMANDS } from "./new-composer/defaults" +import { ComposerInputLayer } from "./new-composer/input-layer" +import { PermissionBody } from "./new-composer/permission-body" +import { ComposerPopover } from "./new-composer/popover" +import { QuestionBody, QuestionSizer } from "./new-composer/question-body" +import { ComposerTodoTray } from "./new-composer/todo-tray" +import { ComposerTray } from "./new-composer/tray" +import type { ComposerSource, ContextItem, ImageAttachment, NewComposerProps } from "./new-composer/types" +import { ACCEPTED_FILE_TYPES } from "./new-composer/types" +import { useEditor } from "./new-composer/use-editor" +import { useLayout } from "./new-composer/use-layout" +import { useTodo } from "./new-composer/use-todo" +import { showToast } from "./toast" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +const normalize = (key: string, code?: string) => { + if (code === "Quote") return "'" + if (key === ",") return "comma" + if (key === "+") return "plus" + if (key === " ") return "space" + if (key === "Dead" && code === "Quote") return "'" + return key.toLowerCase() +} + +const match = (config: string | undefined, event: KeyboardEvent) => { + if (!config || config === "none") return false + const key = normalize(event.key, event.code) + + const combos = config.split(",") + for (const combo of combos) { + const kb = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of combo.trim().toLowerCase().split("+")) { + if (part === "ctrl" || part === "control") { + kb.ctrl = true + continue + } + if (part === "meta" || part === "cmd" || part === "command") { + kb.meta = true + continue + } + if (part === "mod") { + if (IS_MAC) kb.meta = true + else kb.ctrl = true + continue + } + if (part === "alt" || part === "option") { + kb.alt = true + continue + } + if (part === "shift") { + kb.shift = true + continue + } + kb.key = part + } + + if ( + kb.key === key && + kb.ctrl === !!event.ctrlKey && + kb.meta === !!event.metaKey && + kb.shift === !!event.shiftKey && + kb.alt === !!event.altKey + ) { + return true + } + } + + return false +} + +/** + * New Composer — split architecture version of composer island. + */ +export const NewComposer: Component = (props) => { + const i18n = useI18n() + const [answers, setAnswers] = createSignal>( + (props.questionAnswers ?? []).reduce>((map, row, i) => { + map[i] = [...row] + return map + }, {}), + ) + const [customs, setCustoms] = createSignal>({}) + const [customOns, setCustomOns] = createSignal>({}) + const [sending, setSending] = createSignal(false) + const [questionSending, setQuestionSending] = createSignal(false) + const [permissionSending, setPermissionSending] = createSignal(false) + const [accept, setAccept] = createSignal(props.accepting ?? false) + const [modelTick, setModelTick] = createSignal(0) + const [images, setImages] = createSignal([]) + const [contexts, setContexts] = createSignal(props.contextItems ?? []) + const [drag, setDrag] = createSignal<"image" | "@mention" | null>(null) + let id = 0 + let editor: HTMLDivElement | undefined + let input: HTMLInputElement | undefined + const pick = () => input?.click() + + const isQuestion = () => props.mode === "question" + const isPermission = () => props.mode === "permission" + const isMulti = () => props.questionMultiple ?? false + const working = createMemo(() => props.working ?? sending()) + const accepting = createMemo(() => props.accepting ?? accept()) + const questionBusy = createMemo(() => props.questionBusy ?? questionSending()) + const permissionBusy = createMemo(() => props.permissionBusy ?? permissionSending()) + const tab = () => props.questionIndex ?? 0 + + const mapFromList = (list: string[][] | undefined) => { + const map: Record = {} + if (!list) return map + list.forEach((row, i) => { + if (!row) return + map[i] = [...row] + }) + return map + } + + const listFromMap = (map: Record) => { + const keys = Object.keys(map).map(Number) + const count = Math.max(props.questionTotal ?? 0, keys.length > 0 ? Math.max(...keys) + 1 : 0) + return Array.from({ length: count }, (_, i) => map[i] ?? []) + } + + const setAnswerMap = ( + next: Record | ((prev: Record) => Record), + ) => { + setAnswers((prev) => { + const map = typeof next === "function" ? next(prev) : next + props.onQuestionAnswersChange?.(listFromMap(map)) + return map + }) + } + + const selected = createMemo(() => answers()[tab()] ?? []) + const custom = createMemo(() => customs()[tab()] ?? "") + const customOn = createMemo(() => customOns()[tab()] ?? false) + const answered = createMemo(() => { + if (props.questionAnswered) return props.questionAnswered + const total = props.questionTotal ?? 0 + const map = answers() + const text = customs() + const flags = customOns() + return Array.from({ length: total }, (_, i) => { + if ((map[i]?.length ?? 0) > 0) return true + if (flags[i] === true && (text[i] ?? "").trim().length > 0) return true + return false + }) + }) + const activeDrag = createMemo(() => props.forceDragType ?? drag()) + + const [trayRef, setTrayRef] = createSignal() + const trayHeight = useElementHeight(trayRef, 42) + const trayOverlap = 14 + + createEffect( + on( + () => props.questionAnswers, + (next) => { + if (next === undefined) return + setAnswers(mapFromList(next)) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => props.contextItems, + (next) => { + if (next) setContexts(next) + }, + ), + ) + + createEffect( + on( + () => props.accepting, + (next) => { + if (next === undefined) return + setAccept(next) + }, + { defer: true }, + ), + ) + + createEffect(() => { + const i = tab() + const list = answers()[i] ?? [] + const options = new Set((props.questionOptions ?? []).map((item) => item.label.trim())) + const free = list.find((item) => !options.has(item.trim())) ?? "" + + setCustoms((map) => { + if ((map[i] ?? "") === free) return map + return { ...map, [i]: free } + }) + + const hasOption = list.some((item) => options.has(item.trim())) + setCustomOns((map) => { + const prev = map[i] ?? false + const next = free ? true : hasOption ? false : prev + if (prev === next) return map + return { ...map, [i]: next } + }) + }) + + const toggleAccept = () => { + if (props.runtime?.toggleAccept) { + void Promise.resolve() + .then(() => props.runtime?.toggleAccept?.()) + .catch(() => {}) + return + } + if (props.onAcceptToggle) { + void Promise.resolve() + .then(() => props.onAcceptToggle?.()) + .catch(() => {}) + return + } + setAccept((value) => !value) + } + + const cycle = (list: string[] | undefined, curr: string | undefined, pick?: (value: string) => void) => { + if (!list || list.length === 0 || !pick) return + const i = curr ? list.findIndex((item) => item === curr) : -1 + const next = i < 0 ? 0 : (i + 1) % list.length + const value = list[next] + if (!value) return + pick(value) + } + + const openModel = () => { + if (props.runtime?.openModel) { + void Promise.resolve() + .then(() => props.runtime?.openModel?.()) + .catch(() => {}) + return + } + if (props.onModelOpen) { + void Promise.resolve() + .then(() => props.onModelOpen?.()) + .catch(() => {}) + return + } + setModelTick((value) => value + 1) + } + + const cycleAgent = () => { + if (props.runtime?.cycleAgent) { + void Promise.resolve() + .then(() => props.runtime?.cycleAgent?.()) + .catch(() => {}) + return + } + if (props.onAgentCycle) { + void Promise.resolve() + .then(() => props.onAgentCycle?.()) + .catch(() => {}) + return + } + cycle(props.agentOptions, props.agentCurrent ?? props.agentName, props.onAgentSelect) + } + + const cycleVariant = () => { + if (props.runtime?.cycleVariant) { + void Promise.resolve() + .then(() => props.runtime?.cycleVariant?.()) + .catch(() => {}) + return + } + if (props.onVariantCycle) { + void Promise.resolve() + .then(() => props.onVariantCycle?.()) + .catch(() => {}) + return + } + cycle(props.variantOptions, props.variantCurrent ?? props.variant, props.onVariantSelect) + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + const target = event.target + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return + } + + if (match(props.modelKeybind ?? "mod+'", event)) { + event.preventDefault() + event.stopPropagation() + openModel() + return + } + + if (match(props.agentKeybind ?? "mod+.", event)) { + event.preventDefault() + event.stopPropagation() + cycleAgent() + return + } + + if (match(props.variantKeybind ?? "shift+mod+d", event)) { + event.preventDefault() + event.stopPropagation() + cycleVariant() + } + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + const layout = useLayout({ + isQuestion, + isPermission, + imageCount: () => images().length, + contextCount: () => contexts().length, + heightSpring: props.heightSpring, + morphSpring: props.morphSpring, + }) + + const edit = useEditor({ + value: props.value, + onValueChange: props.onValueChange, + onSubmit: (input) => { + if (working()) { + if (props.runtime?.abort) { + void Promise.resolve() + .then(() => props.runtime?.abort?.()) + .catch(() => {}) + return + } + void Promise.resolve() + .then(() => props.onAbort?.()) + .catch(() => {}) + return + } + + const payload = { + source: input.source, + mode: input.mode, + text: input.text, + parts: input.parts, + files: images(), + context: contexts(), + } + + const text = payload.text.trim() + if (!text && payload.files.length === 0 && payload.context.length === 0) return + if (!props.runtime?.submit && !props.onSubmit) return + + const run = props.runtime?.submit ? () => props.runtime?.submit?.(payload) : () => props.onSubmit?.(payload) + + setSending(true) + void Promise.resolve() + .then(run) + .then(() => { + setImages([]) + edit.setText("") + }) + .catch(() => {}) + .finally(() => { + setSending(false) + }) + }, + onAbort: () => { + if (props.runtime?.abort) { + return Promise.resolve() + .then(() => props.runtime?.abort?.()) + .catch(() => {}) + } + if (props.onAbort) { + return Promise.resolve() + .then(() => props.onAbort?.()) + .catch(() => {}) + } + if (!sending()) return + setSending(false) + }, + onAuto: toggleAccept, + onPick: pick, + onModel: openModel, + onAgent: cycleAgent, + onVariant: cycleVariant, + modelKeybind: props.modelKeybind ?? "mod+'", + agentKeybind: props.agentKeybind ?? "mod+.", + variantKeybind: props.variantKeybind ?? "shift+mod+d", + onSlash: props.runtime?.runSlash ?? props.onSlashCommand, + historyRead: props.runtime?.historyRead ?? props.historyRead, + historyWrite: props.runtime?.historyWrite ?? props.historyWrite, + working, + atOptions: props.runtime?.searchAt ?? props.atOptions ?? DEFAULT_AT_OPTIONS, + slashCommands: props.runtime?.searchSlash ?? props.slashCommands ?? DEFAULT_SLASH_COMMANDS, + editor: () => editor, + measure: () => layout.measure(editor), + }) + + const listAnswers = () => { + const count = Math.max((props.questionTotal ?? 0) || 0, tab() + 1) + return Array.from({ length: count }, (_, i) => answers()[i] ?? []) + } + + const updateCustom = (value: string, on: boolean = customOn()) => { + const i = tab() + const prev = (customs()[i] ?? "").trim() + const next = value.trim() + + setCustoms((map) => ({ ...map, [i]: value })) + if (!on) return + + if (isMulti()) { + setAnswerMap((map) => { + const list = map[i] ?? [] + const clean = prev ? list.filter((item) => item.trim() !== prev) : list + if (!next) return { ...map, [i]: clean } + if (clean.some((item) => item.trim() === next)) return { ...map, [i]: clean } + return { ...map, [i]: [...clean, next] } + }) + return + } + setAnswerMap((map) => ({ ...map, [i]: next ? [next] : [] })) + } + + const setCustom = (value: string) => { + updateCustom(value) + } + + const setCustomOn = (value: boolean) => { + const i = tab() + setCustomOns((map) => ({ ...map, [i]: value })) + if (value) { + updateCustom(custom(), true) + return + } + + const text = custom().trim() + if (!text) return + setAnswerMap((map) => { + const list = map[i] ?? [] + return { ...map, [i]: list.filter((item) => item.trim() !== text) } + }) + } + + const questionSubmit = () => { + if (questionBusy()) return + const run = props.runtime?.submitQuestion ?? props.onQuestionSubmit + if (!run) { + props.onQuestionNext?.() + return + } + + setQuestionSending(true) + void Promise.resolve() + .then(() => run(listAnswers())) + .catch(() => {}) + .finally(() => { + setQuestionSending(false) + }) + } + + const questionNext = () => { + if (questionBusy()) return + const last = (props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 + if (last) { + questionSubmit() + return + } + props.onQuestionNext?.() + } + + const questionForward = () => { + if (questionBusy()) return + const last = (props.questionIndex ?? 0) >= (props.questionTotal ?? 1) - 1 + if (last) return + props.onQuestionNext?.() + } + + const questionBack = () => { + if (questionBusy()) return + props.onQuestionBack?.() + } + + const questionDismiss = () => { + if (questionBusy()) return + + const run = props.runtime?.rejectQuestion ?? props.onQuestionReject + if (!run) { + props.onQuestionDismiss?.() + return + } + + setQuestionSending(true) + void Promise.resolve() + .then(run) + .then(() => { + props.onQuestionDismiss?.() + }) + .catch(() => {}) + .finally(() => { + setQuestionSending(false) + }) + } + + const decidePermission = (response: "once" | "always" | "reject") => { + if (permissionBusy()) return + + const run = props.runtime?.decidePermission ?? props.onPermissionDecide + if (!run) return + + setPermissionSending(true) + void Promise.resolve() + .then(() => run(response)) + .catch(() => {}) + .finally(() => { + setPermissionSending(false) + }) + } + + const todo = useTodo({ + todos: () => props.todos ?? [], + show: () => props.showTodos ?? false, + blocked: () => isQuestion() || isPermission(), + collapsed: () => props.todoCollapsed, + onCollapsed: props.onTodoCollapseChange, + shellHeight: layout.height, + trayHeight, + trayOverlap, + }) + + const toggleOption = (label: string) => { + const index = tab() + if (isMulti()) { + setAnswerMap((prev) => { + const list = prev[index] ?? [] + const next = list.includes(label) ? list.filter((item) => item !== label) : [...list, label] + return { ...prev, [index]: next } + }) + return + } + setCustomOns((prev) => ({ ...prev, [index]: false })) + setAnswerMap((prev) => ({ ...prev, [index]: [label] })) + } + + const ctxKey = (item: ContextItem) => { + if (item.id) return `id:${item.id}` + if (item.commentID) return `comment:${item.path}:${item.commentID}` + const start = item.selection?.startLine ?? 0 + const end = item.selection?.endLine ?? 0 + return `path:${item.path}:${start}:${end}` + } + + const dropContext = (item: ContextItem) => { + const id = ctxKey(item) + setContexts((prev) => prev.filter((x) => ctxKey(x) !== id)) + props.runtime?.removeContext?.(item) + props.onContextDrop?.(item) + } + + const openContext = (item: ContextItem) => { + props.runtime?.openContext?.(item) + props.onContextOpen?.(item) + } + + const rejectFile = (source: "paste" | "drop" | "pick", file?: File) => { + const input = { source, file } + const runtime = props.runtime?.fileRejected + const handler = props.onFileRejected + + if (runtime) { + runtime(input) + return + } + if (handler) { + handler(input) + return + } + if (source !== "paste") return + + showToast({ + title: i18n.t("ui.prompt.toast.pasteUnsupported.title"), + description: i18n.t("ui.prompt.toast.pasteUnsupported.description"), + }) + } + + const dialogOn = () => props.runtime?.dialogActive?.() ?? props.dialogActive ?? false + + const addFile = (file: File, source: "paste" | "drop" | "pick") => { + if (!ACCEPTED_FILE_TYPES.includes(file.type)) { + rejectFile(source, file) + return false + } + + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + setImages((prev) => [ + ...prev, + { + id: `img-${++id}-${Date.now()}`, + filename: file.name, + mime: file.type, + dataUrl, + }, + ]) + } + reader.readAsDataURL(file) + return true + } + + const dropImage = (itemID: string) => setImages((prev) => prev.filter((item) => item.id !== itemID)) + + const handlePaste = (event: ClipboardEvent) => { + const data = event.clipboardData + if (!data) return + event.preventDefault() + event.stopPropagation() + + const items = Array.from(data.items) + const fileItems = items.filter((item) => item.kind === "file") + const accepted = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type)) + + if (accepted.length > 0) { + for (const item of accepted) { + const file = item.getAsFile() + if (file) addFile(file, "paste") + } + return + } + + if (fileItems.length > 0) { + const file = fileItems[0]?.getAsFile() ?? undefined + rejectFile("paste", file) + return + } + + const text = data.getData("text/plain") ?? "" + if (!text) { + const read = props.runtime?.readClipboardImage ?? props.readClipboardImage + if (!read) return + void Promise.resolve() + .then(read) + .then((file) => { + if (!file) return + addFile(file, "paste") + }) + .catch(() => {}) + return + } + + document.execCommand("insertText", false, text) + } + + const handleGlobalDragOver = (event: DragEvent) => { + if (dialogOn()) return + event.preventDefault() + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy" + + const hasFiles = event.dataTransfer?.types.includes("Files") + if (hasFiles) { + setDrag("image") + return + } + + const hasText = event.dataTransfer?.types.includes("text/plain") + if (hasText) { + setDrag("@mention") + return + } + + setDrag(null) + } + + const handleGlobalDragLeave = (event: DragEvent) => { + if (dialogOn()) return + if (!event.relatedTarget) setDrag(null) + } + + const handleGlobalDrop = (event: DragEvent) => { + if (dialogOn()) return + event.preventDefault() + setDrag(null) + + const text = event.dataTransfer?.getData("text/plain") + if (text?.startsWith("file:")) { + edit.insertFile(text.slice(5)) + return + } + + const list = event.dataTransfer?.files + if (!list) return + for (const file of Array.from(list)) { + addFile(file, "drop") + } + } + + const onPick = (event: Event) => { + const target = event.currentTarget as HTMLInputElement + const files = target.files + if (!files) return + for (const file of Array.from(files)) addFile(file, "pick") + target.value = "" + } + + const send = (source: ComposerSource = "button") => { + if (working()) { + if (props.runtime?.abort) { + void Promise.resolve() + .then(() => props.runtime?.abort?.()) + .catch(() => {}) + } else { + void Promise.resolve() + .then(() => props.onAbort?.()) + .catch(() => {}) + } + return + } + edit.submit(source) + } + + const pickShortcut = (n: number) => { + const opts = props.questionOptions ?? [] + if (n <= 0) return + + const option = opts[n - 1] + if (option) { + toggleOption(option.label) + return + } + + if (n === opts.length + 1) { + setCustomOn(true) + const input = document.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + } + } + + onMount(() => { + const onKey = (event: KeyboardEvent) => { + if (!isQuestion()) return + if (questionBusy()) return + + const target = event.target + const writing = + target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + + if ( + event.key === "Escape" && + target instanceof HTMLTextAreaElement && + target.dataset.slot === "question-custom-input" + ) { + target.blur() + event.preventDefault() + return + } + + if ( + event.key === "Enter" && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + target instanceof HTMLTextAreaElement && + target.dataset.slot === "question-custom-input" + ) { + target.blur() + event.preventDefault() + return + } + + const mod = event.metaKey || event.ctrlKey + if (mod && event.key === "Enter") { + event.preventDefault() + questionSubmit() + return + } + + if (writing) return + + if (event.key >= "1" && event.key <= "9") { + pickShortcut(Number(event.key)) + event.preventDefault() + return + } + + if (event.key === "ArrowLeft") { + questionBack() + event.preventDefault() + return + } + + if (event.key === "ArrowRight") { + questionForward() + event.preventDefault() + return + } + + if (event.key !== "Enter") return + event.preventDefault() + questionNext() + } + + window.addEventListener("keydown", onKey) + onCleanup(() => window.removeEventListener("keydown", onKey)) + }) + + onMount(() => { + document.addEventListener("dragover", handleGlobalDragOver) + document.addEventListener("dragleave", handleGlobalDragLeave) + document.addEventListener("drop", handleGlobalDrop) + + onCleanup(() => { + document.removeEventListener("dragover", handleGlobalDragOver) + document.removeEventListener("dragleave", handleGlobalDragLeave) + document.removeEventListener("drop", handleGlobalDrop) + }) + }) + + return ( +
+ + + 0.001}> + + + + + +
+ + + + + + +
0.5 ? "none" : "auto", + }} + > + send("button")} + onEditorRef={(el) => { + editor = el + requestAnimationFrame(() => { + layout.measure(editor) + el.focus() + }) + }} + onInput={edit.handleInput} + onKeyDown={edit.handleKeyDown} + onPaste={handlePaste} + onCompStart={() => edit.setComposing(true)} + onCompEnd={() => edit.setComposing(false)} + /> +
+ +
+ + +
+
+ +
+
+
+ +
+
+ +
+
+
+
+
+
+ + { + input = el + }} + type="file" + multiple + accept={ACCEPTED_FILE_TYPES.join(",")} + style={{ display: "none" }} + onChange={onPick} + /> +
+ ) +} + +export const Composer = NewComposer + +export type { + AtOption, + ComposerHistoryItem, + ContextItem, + ImageAttachment, + NewComposerProps, + SlashCommand, + TodoItem, +} from "./new-composer/types" diff --git a/packages/ui/src/components/new-composer/defaults.ts b/packages/ui/src/components/new-composer/defaults.ts new file mode 100644 index 0000000000..d0c61ea642 --- /dev/null +++ b/packages/ui/src/components/new-composer/defaults.ts @@ -0,0 +1,64 @@ +import type { AtOption, SlashCommand } from "./types" + +export const DEFAULT_AT_OPTIONS: AtOption[] = [ + { type: "file", path: "src/auth.ts", display: "src/auth.ts" }, + { type: "file", path: "src/middleware.ts", display: "src/middleware.ts" }, + { type: "file", path: "src/routes/login.ts", display: "src/routes/login.ts" }, + { type: "file", path: "src/utils/token.ts", display: "src/utils/token.ts" }, + { type: "file", path: "src/config/database.ts", display: "src/config/database.ts" }, + { type: "agent", name: "coder", display: "coder" }, + { type: "agent", name: "reviewer", display: "reviewer" }, + { type: "agent", name: "planner", display: "planner" }, +] + +export const DEFAULT_SLASH_COMMANDS: SlashCommand[] = [ + { + id: "help", + trigger: "help", + title: "Help", + description: "Show available commands", + type: "builtin", + keybind: "?", + source: "command", + }, + { + id: "clear", + trigger: "clear", + title: "Clear", + description: "Clear conversation", + type: "builtin", + source: "command", + }, + { + id: "compact", + trigger: "compact", + title: "Compact", + description: "Compact conversation history", + type: "builtin", + source: "command", + }, + { + id: "init", + trigger: "init", + title: "Init", + description: "Initialize CLAUDE.md", + type: "builtin", + source: "command", + }, + { + id: "review", + trigger: "review", + title: "Review", + description: "Review code changes", + type: "custom", + source: "skill", + }, + { + id: "test", + trigger: "test", + title: "Test", + description: "Run tests", + type: "custom", + source: "mcp", + }, +] diff --git a/packages/ui/src/components/new-composer/drag-overlay.tsx b/packages/ui/src/components/new-composer/drag-overlay.tsx new file mode 100644 index 0000000000..7869f13b26 --- /dev/null +++ b/packages/ui/src/components/new-composer/drag-overlay.tsx @@ -0,0 +1,23 @@ +import { Show } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" + +interface Props { + type: "image" | "@mention" | null +} + +export function ComposerDragOverlay(props: Props) { + const i18n = useI18n() + return ( + +
+
+ + + {props.type === "image" ? i18n.t("ui.prompt.dropzone.attach") : i18n.t("ui.prompt.dropzone.mention")} + +
+
+
+ ) +} diff --git a/packages/ui/src/components/new-composer/editor-utils.test.ts b/packages/ui/src/components/new-composer/editor-utils.test.ts new file mode 100644 index 0000000000..5ebfe40ef2 --- /dev/null +++ b/packages/ui/src/components/new-composer/editor-utils.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from "bun:test" +import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-utils" + +describe("new-composer editor utils", () => { + test("createTextFragment preserves newlines with consecutive br nodes", () => { + const fragment = createTextFragment("foo\n\nbar") + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(4) + expect(box.childNodes[0]?.textContent).toBe("foo") + expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR") + expect((box.childNodes[2] as HTMLElement).tagName).toBe("BR") + expect(box.childNodes[3]?.textContent).toBe("bar") + }) + + test("createTextFragment keeps trailing newline as terminal break", () => { + const fragment = createTextFragment("foo\n") + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(2) + expect(box.childNodes[0]?.textContent).toBe("foo") + expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR") + }) + + test("createTextFragment avoids break-node explosion for large multiline content", () => { + const value = Array.from({ length: 220 }, () => "line").join("\n") + const fragment = createTextFragment(value) + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(1) + expect(box.childNodes[0]?.nodeType).toBe(Node.TEXT_NODE) + expect(box.textContent).toBe(value) + }) + + test("createTextFragment keeps terminal break in large multiline fallback", () => { + const value = `${Array.from({ length: 220 }, () => "line").join("\n")}\n` + const fragment = createTextFragment(value) + const box = document.createElement("div") + box.appendChild(fragment) + + expect(box.childNodes.length).toBe(2) + expect(box.childNodes[0]?.textContent).toBe(value.slice(0, -1)) + expect((box.childNodes[1] as HTMLElement).tagName).toBe("BR") + }) + + test("length helpers treat breaks as one char and ignore zero-width chars", () => { + const box = document.createElement("div") + box.appendChild(document.createTextNode("ab\u200B")) + box.appendChild(document.createElement("br")) + box.appendChild(document.createTextNode("cd")) + + expect(getNodeLength(box.childNodes[0]!)).toBe(2) + expect(getNodeLength(box.childNodes[1]!)).toBe(1) + expect(getTextLength(box)).toBe(5) + }) + + test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => { + const box = document.createElement("div") + const pill = document.createElement("span") + pill.dataset.type = "file" + pill.textContent = "@file" + + box.appendChild(document.createTextNode("ab")) + box.appendChild(pill) + box.appendChild(document.createElement("br")) + box.appendChild(document.createTextNode("cd")) + document.body.appendChild(box) + + setCursorPosition(box, 2) + expect(getCursorPosition(box)).toBe(2) + + setCursorPosition(box, 7) + expect(getCursorPosition(box)).toBe(7) + + setCursorPosition(box, 8) + expect(getCursorPosition(box)).toBe(8) + + box.remove() + }) + + test("setCursorPosition and getCursorPosition round-trip across blank lines", () => { + const box = document.createElement("div") + box.appendChild(document.createTextNode("a")) + box.appendChild(document.createElement("br")) + box.appendChild(document.createElement("br")) + box.appendChild(document.createTextNode("b")) + document.body.appendChild(box) + + setCursorPosition(box, 2) + expect(getCursorPosition(box)).toBe(2) + + setCursorPosition(box, 3) + expect(getCursorPosition(box)).toBe(3) + + box.remove() + }) +}) diff --git a/packages/ui/src/components/new-composer/editor-utils.ts b/packages/ui/src/components/new-composer/editor-utils.ts new file mode 100644 index 0000000000..c2294da3cb --- /dev/null +++ b/packages/ui/src/components/new-composer/editor-utils.ts @@ -0,0 +1,206 @@ +import type { ComposerPart } from "./types" + +const MAX_BREAKS = 200 + +export function createTextFragment(content: string): DocumentFragment { + const fragment = document.createDocumentFragment() + + let breaks = 0 + for (const char of content) { + if (char !== "\n") continue + breaks += 1 + if (breaks > MAX_BREAKS) { + const tail = content.endsWith("\n") + const text = tail ? content.slice(0, -1) : content + if (text) fragment.appendChild(document.createTextNode(text)) + if (tail) fragment.appendChild(document.createElement("br")) + return fragment + } + } + + const parts = content.split("\n") + parts.forEach((part, i) => { + if (part) fragment.appendChild(document.createTextNode(part)) + if (i < parts.length - 1) fragment.appendChild(document.createElement("br")) + }) + return fragment +} + +export function getNodeLength(node: Node): number { + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + return (node.textContent ?? "").replace(/\u200B/g, "").length +} + +export function getTextLength(node: Node): number { + if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length + if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1 + let len = 0 + for (const child of Array.from(node.childNodes)) len += getTextLength(child) + return len +} + +export function getCursorPosition(parent: HTMLElement): number { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return 0 + const range = selection.getRangeAt(0) + if (!parent.contains(range.startContainer)) return 0 + const copy = range.cloneRange() + copy.selectNodeContents(parent) + copy.setEnd(range.startContainer, range.startOffset) + return getTextLength(copy.cloneContents()) +} + +export function setCursorPosition(parent: HTMLElement, position: number) { + let rest = position + let node = parent.firstChild + while (node) { + const len = getNodeLength(node) + const text = node.nodeType === Node.TEXT_NODE + const pill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (text && rest <= len) { + const range = document.createRange() + const selection = window.getSelection() + range.setStart(node, rest) + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + if ((pill || br) && rest <= len) { + const range = document.createRange() + const selection = window.getSelection() + if (rest === 0) range.setStartBefore(node) + else if (pill) range.setStartAfter(node) + else { + const next = node.nextSibling + if (next && next.nodeType === Node.TEXT_NODE) range.setStart(next, 0) + else range.setStartAfter(node) + } + range.collapse(true) + selection?.removeAllRanges() + selection?.addRange(range) + return + } + rest -= len + node = node.nextSibling + } + + const end = document.createRange() + end.selectNodeContents(parent) + end.collapse(false) + window.getSelection()?.removeAllRanges() + window.getSelection()?.addRange(end) +} + +export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) { + let rest = offset + for (const node of Array.from(parent.childNodes)) { + const len = getNodeLength(node) + const text = node.nodeType === Node.TEXT_NODE + const pill = + node.nodeType === Node.ELEMENT_NODE && + ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent") + const br = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR" + + if (text && rest <= len) { + if (edge === "start") range.setStart(node, rest) + else range.setEnd(node, rest) + return + } + if ((pill || br) && rest <= len) { + if (edge === "start") rest === 0 ? range.setStartBefore(node) : range.setStartAfter(node) + else rest === 0 ? range.setEndBefore(node) : range.setEndAfter(node) + return + } + rest -= len + } +} + +export function createPill(type: "file" | "agent", content: string, path?: string) { + const el = document.createElement("span") + el.textContent = content + el.setAttribute("data-type", type) + if (type === "file" && path) el.setAttribute("data-path", path) + if (type === "agent") el.setAttribute("data-name", content.replace("@", "")) + el.setAttribute("contenteditable", "false") + el.style.userSelect = "text" + el.style.cursor = "default" + return el +} + +export function parseEditorText(editor: HTMLElement): string { + return parseEditorParts(editor) + .map((part) => part.content) + .join("") +} + +function pushText(parts: ComposerPart[], text: string) { + if (!text) return + const last = parts[parts.length - 1] + if (last?.type === "text") { + last.content += text + return + } + parts.push({ type: "text", content: text }) +} + +export function parseEditorParts(editor: HTMLElement): ComposerPart[] { + const parts: ComposerPart[] = [] + let text = "" + + const flush = () => { + if (!text) return + pushText(parts, text) + text = "" + } + + const visit = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + text += (node.textContent ?? "").replace(/\u200B/g, "") + return + } + if (node.nodeType !== Node.ELEMENT_NODE) return + const el = node as HTMLElement + if (el.dataset.type === "file" || el.dataset.type === "agent") { + flush() + const content = el.textContent ?? "" + if (el.dataset.type === "file") { + parts.push({ + type: "file", + path: el.dataset.path ?? content, + content, + }) + } + if (el.dataset.type === "agent") { + parts.push({ + type: "agent", + name: el.dataset.name ?? content.replace(/^@/, ""), + content, + }) + } + return + } + if (el.tagName === "BR") { + text += "\n" + return + } + for (const child of Array.from(el.childNodes)) visit(child) + } + + const nodes = Array.from(editor.childNodes) + nodes.forEach((node, i) => { + const block = node.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((node as HTMLElement).tagName) + visit(node) + if (block && i < nodes.length - 1) text += "\n" + }) + flush() + return parts +} + +export function isImeComposing(event: KeyboardEvent): boolean { + return event.isComposing || (event as unknown as { keyCode?: number }).keyCode === 229 +} diff --git a/packages/ui/src/components/new-composer/history.test.ts b/packages/ui/src/components/new-composer/history.test.ts new file mode 100644 index 0000000000..67f6695eaa --- /dev/null +++ b/packages/ui/src/components/new-composer/history.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from "bun:test" +import { + canNavigateAtCursor, + cloneParts, + navigateEntry, + normalizeEntry, + partText, + prependEntry, + type HistoryEntry, +} from "./history" + +const text = (value: string): HistoryEntry => ({ + text: value, + parts: [{ type: "text", content: value }], +}) + +describe("new-composer history", () => { + test("prependEntry skips empty entries and deduplicates consecutive entries", () => { + const blank: HistoryEntry[] = [] + const first = prependEntry(blank, text("")) + expect(first).toBe(blank) + + const one = prependEntry([], text("hello")) + expect(one).toHaveLength(1) + + const dup = prependEntry(one, text("hello")) + expect(dup).toBe(one) + }) + + test("navigateEntry restores saved draft when moving down from newest", () => { + const entries = [text("third"), text("second"), text("first")] + + const up = navigateEntry({ + direction: "up", + entries, + historyIndex: -1, + current: text("draft"), + saved: null, + }) + + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.historyIndex).toBe(0) + expect(up.cursor).toBe("start") + expect(up.entry.text).toBe("third") + + const down = navigateEntry({ + direction: "down", + entries, + historyIndex: up.historyIndex, + current: text("ignored"), + saved: up.saved, + }) + + expect(down.handled).toBe(true) + if (!down.handled) throw new Error("expected handled") + expect(down.historyIndex).toBe(-1) + expect(down.entry.text).toBe("draft") + expect(down.entry.parts).toEqual([{ type: "text", content: "draft" }]) + }) + + test("navigateEntry keeps structured parts when navigating history", () => { + const entry: HistoryEntry = { + text: "@src/auth.ts", + parts: [{ type: "file", path: "src/auth.ts", content: "@src/auth.ts" }], + } + + const up = navigateEntry({ + direction: "up", + entries: [entry], + historyIndex: -1, + current: text("draft"), + saved: null, + }) + + expect(up.handled).toBe(true) + if (!up.handled) throw new Error("expected handled") + expect(up.entry.parts).toEqual(entry.parts) + }) + + test("normalizeEntry supports legacy string entries", () => { + const entry = normalizeEntry("legacy") + expect(entry.text).toBe("legacy") + expect(entry.parts).toEqual([{ type: "text", content: "legacy" }]) + }) + + test("helpers clone parts and count combined content", () => { + const src = [ + { type: "text", content: "one" }, + { type: "file", path: "src/a.ts", content: "@src/a.ts" }, + { type: "agent", name: "coder", content: "@coder" }, + ] as const + + const copy = cloneParts([...src]) + expect(copy).not.toBe(src) + expect(partText(copy)).toBe("one@src/a.ts@coder") + + if (copy[1]?.type !== "file") throw new Error("expected file") + copy[1].path = "src/b.ts" + if (src[1].type !== "file") throw new Error("expected file") + expect(src[1].path).toBe("src/a.ts") + }) + + test("canNavigateAtCursor only allows history navigation at boundaries", () => { + const value = "a\nb\nc" + + expect(canNavigateAtCursor("up", value, 0)).toBe(true) + expect(canNavigateAtCursor("down", value, 0)).toBe(false) + + expect(canNavigateAtCursor("up", value, 2)).toBe(false) + expect(canNavigateAtCursor("down", value, 2)).toBe(false) + + expect(canNavigateAtCursor("up", value, 5)).toBe(false) + expect(canNavigateAtCursor("down", value, 5)).toBe(true) + + expect(canNavigateAtCursor("up", "abc", 0)).toBe(true) + expect(canNavigateAtCursor("down", "abc", 3)).toBe(true) + expect(canNavigateAtCursor("up", "abc", 1)).toBe(false) + expect(canNavigateAtCursor("down", "abc", 1)).toBe(false) + + expect(canNavigateAtCursor("up", "abc", 0, true)).toBe(true) + expect(canNavigateAtCursor("up", "abc", 3, true)).toBe(true) + expect(canNavigateAtCursor("down", "abc", 0, true)).toBe(true) + expect(canNavigateAtCursor("down", "abc", 3, true)).toBe(true) + expect(canNavigateAtCursor("up", "abc", 1, true)).toBe(false) + expect(canNavigateAtCursor("down", "abc", 1, true)).toBe(false) + }) +}) diff --git a/packages/ui/src/components/new-composer/history.ts b/packages/ui/src/components/new-composer/history.ts new file mode 100644 index 0000000000..ff88ecb12e --- /dev/null +++ b/packages/ui/src/components/new-composer/history.ts @@ -0,0 +1,189 @@ +import type { ComposerHistoryItem, ComposerPart } from "./types" + +export type HistoryEntry = { + text: string + parts: ComposerPart[] +} + +export type HistoryStored = string | ComposerHistoryItem + +export type NavInput = { + direction: "up" | "down" + entries: HistoryStored[] + historyIndex: number + current: HistoryEntry + saved: HistoryEntry | null +} + +export type NavResult = + | { + handled: false + historyIndex: number + saved: HistoryEntry | null + } + | { + handled: true + historyIndex: number + saved: HistoryEntry | null + entry: HistoryEntry + cursor: "start" | "end" + } + +export const MAX_HISTORY = 50 + +export const EMPTY_ENTRY: HistoryEntry = { + text: "", + parts: [{ type: "text", content: "" }], +} + +const clonePart = (part: ComposerPart): ComposerPart => { + if (part.type === "text") return { ...part } + if (part.type === "file") return { ...part } + return { ...part } +} + +export const cloneParts = (parts: ComposerPart[]) => parts.map(clonePart) + +const cloneEntry = (entry: HistoryEntry): HistoryEntry => ({ + text: entry.text, + parts: cloneParts(entry.parts), +}) + +export const partText = (parts: ComposerPart[]) => parts.map((part) => part.content).join("") + +export const normalizeEntry = (item: HistoryStored): HistoryEntry => { + if (typeof item === "string") { + return { + text: item, + parts: [{ type: "text", content: item }], + } + } + + if (!item.parts || item.parts.length === 0) { + return { + text: item.text, + parts: [{ type: "text", content: item.text }], + } + } + + const parts = cloneParts(item.parts) + return { + text: partText(parts), + parts, + } +} + +const samePart = (a: ComposerPart, b: ComposerPart) => { + if (a.type !== b.type) return false + if (a.type === "text" && b.type === "text") return a.content === b.content + if (a.type === "file" && b.type === "file") return a.path === b.path && a.content === b.content + if (a.type === "agent" && b.type === "agent") return a.name === b.name && a.content === b.content + return false +} + +export const sameEntry = (a: HistoryEntry | undefined, b: HistoryEntry) => { + if (!a) return false + if (a.text !== b.text) return false + if (a.parts.length !== b.parts.length) return false + + for (let i = 0; i < a.parts.length; i++) { + const x = a.parts[i] + const y = b.parts[i] + if (!x || !y || !samePart(x, y)) return false + } + + return true +} + +export const prependEntry = (entries: HistoryEntry[], item: HistoryEntry, max = MAX_HISTORY) => { + if (!item.text.trim()) return entries + + const next = normalizeEntry(item) + if (sameEntry(entries[0], next)) return entries + return [next, ...entries].slice(0, max) +} + +export const canNavigateAtCursor = (direction: "up" | "down", text: string, cursor: number, inHistory = false) => { + const pos = Math.max(0, Math.min(cursor, text.length)) + const start = pos === 0 + const end = pos === text.length + if (inHistory) return start || end + if (direction === "up") return start + return end +} + +export const navigateEntry = (input: NavInput): NavResult => { + if (input.direction === "up") { + if (input.entries.length === 0) { + return { + handled: false, + historyIndex: input.historyIndex, + saved: input.saved, + } + } + + if (input.historyIndex === -1) { + return { + handled: true, + historyIndex: 0, + saved: cloneEntry(input.current), + entry: normalizeEntry(input.entries[0] ?? EMPTY_ENTRY), + cursor: "start", + } + } + + if (input.historyIndex < input.entries.length - 1) { + const next = input.historyIndex + 1 + return { + handled: true, + historyIndex: next, + saved: input.saved, + entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY), + cursor: "start", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + saved: input.saved, + } + } + + if (input.historyIndex > 0) { + const next = input.historyIndex - 1 + return { + handled: true, + historyIndex: next, + saved: input.saved, + entry: normalizeEntry(input.entries[next] ?? EMPTY_ENTRY), + cursor: "end", + } + } + + if (input.historyIndex === 0) { + if (input.saved) { + return { + handled: true, + historyIndex: -1, + saved: null, + entry: cloneEntry(input.saved), + cursor: "end", + } + } + + return { + handled: true, + historyIndex: -1, + saved: null, + entry: normalizeEntry(EMPTY_ENTRY), + cursor: "end", + } + } + + return { + handled: false, + historyIndex: input.historyIndex, + saved: input.saved, + } +} diff --git a/packages/ui/src/components/new-composer/input-layer.tsx b/packages/ui/src/components/new-composer/input-layer.tsx new file mode 100644 index 0000000000..4ecc93158a --- /dev/null +++ b/packages/ui/src/components/new-composer/input-layer.tsx @@ -0,0 +1,223 @@ +import { For, Show } from "solid-js" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { useI18n } from "../../context/i18n" +import { Button } from "../button" +import { FileIcon } from "../file-icon" +import { Icon } from "../icon" +import { IconButton } from "../icon-button" +import { Tooltip } from "../tooltip" +import type { ContextItem, ImageAttachment } from "./types" + +interface Props { + value: string + mode: "normal" | "shell" + images: ImageAttachment[] + contexts: ContextItem[] + working: boolean + accepting: boolean + placeholder?: string + onImageDrop: (id: string) => void + contextActive?: (item: ContextItem) => boolean + onContextOpen?: (item: ContextItem) => void + onContextDrop: (item: ContextItem) => void + onAccept: () => void + onPick: () => void + onSend: () => void + onEditorRef: (el: HTMLDivElement) => void + onInput: () => void + onKeyDown: (event: KeyboardEvent) => void + onPaste: (event: ClipboardEvent) => void + onCompStart: () => void + onCompEnd: () => void +} + +export function ComposerInputLayer(props: Props) { + const i18n = useI18n() + const isShell = () => props.mode === "shell" + const shell = useSpring(() => (isShell() ? 1 : 0), { visualDuration: 0.1, bounce: 0 }) + const buttonsOpacity = () => 1 - shell() + const buttonsBlur = () => shell() + + return ( + <> + 0}> +
+ + {(item) => ( +
+ + +
+ } + > + {item.filename} + + +
+ {item.filename} +
+
+ )} + + +
+ + 0}> +
+ + {(item) => { + const active = () => props.contextActive?.(item) ?? false + const dir = () => getDirectory(item.path) + const file = () => getFilename(item.path) + const filename = () => getFilenameTruncated(item.path, 14) + const label = () => { + if (!item.selection) return null + if (item.selection.startLine === item.selection.endLine) return `:${item.selection.startLine}` + return `:${item.selection.startLine}-${item.selection.endLine}` + } + return ( + + {dir()} + {file()} + + } + placement="top" + openDelay={2000} + > +
props.onContextOpen?.(item)} + > +
+ +
+ {filename()} + + {label()} + +
+ { + event.stopPropagation() + props.onContextDrop(item) + }} + aria-label={i18n.t("ui.prompt.context.removeFile")} + /> +
+ + {(note) =>
{note()}
} +
+
+
+ ) + }} +
+
+
+ +
+
+ +
+ {props.mode === "shell" + ? i18n.t("ui.prompt.placeholder.shell") + : (props.placeholder ?? i18n.t("ui.prompt.placeholder.simple"))} +
+
+
+ +
0.01 ? `blur(${buttonsBlur()}px)` : "none", + "pointer-events": shell() < 0.5 ? "auto" : "none", + }} + > + +
+ + +
+
+ + ) +} diff --git a/packages/ui/src/components/new-composer/model-picker.tsx b/packages/ui/src/components/new-composer/model-picker.tsx new file mode 100644 index 0000000000..2ae8bf9ea4 --- /dev/null +++ b/packages/ui/src/components/new-composer/model-picker.tsx @@ -0,0 +1,242 @@ +import { Popover as Kobalte } from "@kobalte/core/popover" +import { For, Show, createEffect, createMemo, createSignal, on } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Button } from "../button" +import { Icon } from "../icon" +import { IconButton } from "../icon-button" +import { ProviderIcon } from "../provider-icon" + +interface Props { + options: string[] + current?: string + onSelect?: (value: string) => void + openTick?: number +} + +type Item = { + value: string + provider: string + label: string +} + +function parse(value: string, fallback: string): Item { + const i = value.indexOf("/") + if (i <= 0 || i >= value.length - 1) { + return { + value, + provider: fallback, + label: value, + } + } + + return { + value, + provider: value.slice(0, i), + label: value.slice(i + 1), + } +} + +export function ModelPicker(props: Props) { + const i18n = useI18n() + const [open, setOpen] = createSignal(false) + const [full, setFull] = createSignal(false) + const [query, setQuery] = createSignal("") + let input: HTMLInputElement | undefined + + const focus = () => { + requestAnimationFrame(() => { + input?.focus() + input?.select() + }) + } + + createEffect( + on( + () => props.openTick, + (next, prev) => { + if (next === undefined) return + if (next === prev) return + setFull(true) + setOpen(true) + focus() + }, + { defer: true }, + ), + ) + + const list = createMemo(() => { + const q = query().trim().toLowerCase() + const fallback = i18n.t("ui.prompt.model.group.default") + const items = props.options.map((item) => parse(item, fallback)) + if (!q) return items + return items.filter((item) => { + return item.label.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q) + }) + }) + + const groups = createMemo(() => { + const map = new Map() + for (const item of list()) { + const group = map.get(item.provider) + if (group) { + group.push(item) + continue + } + map.set(item.provider, [item]) + } + return Array.from(map.entries()).map(([provider, items]) => ({ provider, items })) + }) + + const label = createMemo(() => { + const fallback = i18n.t("ui.prompt.model.group.default") + const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current) + if (!hit) return props.current ?? i18n.t("ui.prompt.control.model") + return hit.label + }) + + const provider = createMemo(() => { + const fallback = i18n.t("ui.prompt.model.group.default") + const hit = props.options.map((item) => parse(item, fallback)).find((item) => item.value === props.current) + if (!hit) return undefined + if (hit.provider === fallback) return undefined + return hit.provider.toLowerCase() + }) + + return ( + { + setOpen(next) + if (!next) { + setQuery("") + setFull(false) + } + if (next) focus() + }} + modal={false} + placement="top-start" + gutter={4} + > + setFull(false)} + > + }> + {(id) => ( + + )} + + {label()} + + + + + +
setOpen(false)} /> + + { + setOpen(false) + event.preventDefault() + event.stopPropagation() + }} + > + +
+
{i18n.t("ui.prompt.model.select")}
+ +
+
+ +
+ + setQuery(event.currentTarget.value)} + placeholder={i18n.t("ui.prompt.model.search")} + class="min-w-0 flex-1 bg-transparent border-none outline-none text-14-regular text-text-strong placeholder:text-text-weak" + /> + + + + +
+ +
+ 0} + fallback={
{i18n.t("ui.prompt.model.none")}
} + > + + {(group) => ( +
+
{group.provider}
+ + {(item) => ( + + )} + +
+ )} +
+
+
+ + +
{i18n.t("ui.prompt.model.manage")}
+
+
+ + + ) +} diff --git a/packages/ui/src/components/new-composer/permission-body.tsx b/packages/ui/src/components/new-composer/permission-body.tsx new file mode 100644 index 0000000000..98f13c801a --- /dev/null +++ b/packages/ui/src/components/new-composer/permission-body.tsx @@ -0,0 +1,41 @@ +import { For, Show } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" + +interface Props { + tool?: string + description?: string + patterns?: string[] +} + +export function PermissionBody(props: Props) { + const i18n = useI18n() + const hint = () => props.description ?? props.tool ?? "" + + return ( + <> +
+ + + +
{i18n.t("ui.permission.title")}
+
+ +
+
+
+ 0}> +
+
+
+ + ) +} diff --git a/packages/ui/src/components/new-composer/popover.tsx b/packages/ui/src/components/new-composer/popover.tsx new file mode 100644 index 0000000000..c664004daf --- /dev/null +++ b/packages/ui/src/components/new-composer/popover.tsx @@ -0,0 +1,111 @@ +import { For, Match, Show, Switch } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" +import type { AtOption, SlashCommand } from "./types" + +interface Props { + kind: "at" | "slash" | null + bottom: number + atFlat: AtOption[] + atActive: string | null + atKey: (item: AtOption) => string + onAtHover: (key: string) => void + onAtPick: (item: AtOption) => void + slashFlat: SlashCommand[] + slashActive: string | null + onSlashHover: (key: string) => void + onSlashPick: (item: SlashCommand) => void +} + +export function ComposerPopover(props: Props) { + const i18n = useI18n() + return ( + +
event.preventDefault()} + > + + + 0} + fallback={
{i18n.t("ui.list.empty")}
} + > + + {(item) => { + const key = props.atKey(item) + const isDir = item.type === "file" && item.path.endsWith("/") + const dir = item.type === "file" ? (isDir ? item.path : getDirectory(item.path)) : "" + const file = item.type === "file" && !isDir ? getFilename(item.path) : "" + return ( + + ) + }} + +
+
+ + 0} + fallback={
{i18n.t("ui.prompt.list.noCommands")}
} + > + + {(cmd) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/packages/ui/src/components/new-composer/question-body.tsx b/packages/ui/src/components/new-composer/question-body.tsx new file mode 100644 index 0000000000..47dfda8934 --- /dev/null +++ b/packages/ui/src/components/new-composer/question-body.tsx @@ -0,0 +1,218 @@ +import { For, Index, Show } from "solid-js" +import { useI18n } from "../../context/i18n" +import { Icon } from "../icon" + +interface Option { + label: string + description?: string +} + +interface Base { + text?: string + options?: Option[] + index?: number + total?: number + multi?: boolean + answered?: boolean[] +} + +interface BodyProps extends Base { + selected: string[] + custom: string + customOn: boolean + busy?: boolean + onToggle: (label: string) => void + onCustomOn: (value: boolean) => void + onCustom: (value: string) => void + onJump?: (index: number) => void +} + +function Header(props: Base & { onJump?: (index: number) => void; click?: boolean }) { + const i18n = useI18n() + const tab = () => props.index ?? 0 + const done = (i: number) => { + if (props.answered) return props.answered[i] === true + return i < tab() + } + + return ( + 0}> +
+
+ {i18n.t("ui.question.progress", { index: tab() + 1, total: props.total ?? 0 })} +
+
+ + {(_, i) => ( + + } + > +
+
+
+ ) +} + +export function QuestionBody(props: BodyProps) { + const i18n = useI18n() + const multi = () => props.multi ?? false + const selected = (label: string) => props.selected.includes(label) + const customPicked = () => { + if (props.customOn) return true + if (!multi()) return false + const text = props.custom.trim() + if (!text) return false + return props.selected.some((item) => item.trim() === text) + } + + return ( + <> +
+
+
{props.text}
+ {i18n.t("ui.question.singleHint")}
}> +
{i18n.t("ui.question.multiHint")}
+ +
+ + {(opt) => { + const picked = () => selected(opt.label) + return ( + + ) + }} + + +
{ + if (event.target instanceof HTMLTextAreaElement) return + const input = event.currentTarget.querySelector('[data-slot="question-custom-input"]') + if (input instanceof HTMLTextAreaElement) input.focus() + }} + onSubmit={(event) => event.preventDefault()} + > + + + {i18n.t("ui.messagePart.option.typeOwnAnswer")} +