From 0f5b9a37fcbe31148c62ae03de2661f68b7f653e Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 03:18:40 +0530 Subject: [PATCH 01/32] =?UTF-8?q?=F0=9F=90=9B=20fix(app):=20prevent=20stre?= =?UTF-8?q?aming=20content=20duplication=20during=20event=20coalescing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When message.part.updated events coalesce within a 16ms batch window (text-end replaces text-start at the same queue index), stale message.part.delta events remained in the queue. On flush, the reducer applied the full text from the coalesced update, then appended the stale deltas on top — doubling the content. Fix: When a message.part.updated event coalesces over an earlier entry, void all stale message.part.delta events for the same messageID:partID already in the queue. Voided indices are skipped during flush. Zero overhead in normal operation (voided set stays empty when no coalescing occurs). Scoped by messageID+partID to avoid affecting unrelated parts. Fixes content repetition visible during LLM streaming in desktop UI. Content displays correctly after app restart (loaded from DB). --- packages/app/src/context/global-sdk.tsx | 254 ------------------------ 1 file changed, 254 deletions(-) delete mode 100644 packages/app/src/context/global-sdk.tsx diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx deleted file mode 100644 index 001b90b42ee2..000000000000 --- a/packages/app/src/context/global-sdk.tsx +++ /dev/null @@ -1,254 +0,0 @@ -import type { Event } from "@opencode-ai/sdk/v2/client" -import { createSimpleContext } from "@opencode-ai/ui/context" -import { createGlobalEmitter } from "@solid-primitives/event-bus" -import { makeEventListener } from "@solid-primitives/event-listener" -import { batch, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" -import { useLanguage } from "./language" -import { usePlatform } from "./platform" -import { useServer } from "./server" - -const isAbortError = (error: unknown) => - error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" - -export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ - name: "GlobalSDK", - init: () => { - const language = useLanguage() - const server = useServer() - const platform = usePlatform() - const abort = new AbortController() - - const eventFetch = (() => { - if (!platform.fetch || !server.current) return - try { - const url = new URL(server.current.http.url) - const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" - if (url.protocol === "http:" && !loopback) return platform.fetch - } catch { - return - } - })() - - const currentServer = server.current - if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable")) - - const eventSdk = createSdkForServer({ - signal: abort.signal, - fetch: eventFetch, - server: currentServer.http, - }) - const emitter = createGlobalEmitter<{ - [key: string]: Event - }>() - - type Queued = { directory: string; payload: Event } - const FLUSH_FRAME_MS = 16 - const STREAM_YIELD_MS = 8 - const RECONNECT_DELAY_MS = 250 - - let queue: Queued[] = [] - let buffer: Queued[] = [] - const coalesced = new Map() - const staleDeltas = new Set() - let timer: ReturnType | undefined - let last = 0 - - const deltaKey = (directory: string, messageID: string, partID: string) => `${directory}:${messageID}:${partID}` - - const key = (directory: string, payload: Event) => { - if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}` - if (payload.type === "lsp.updated") return `lsp.updated:${directory}` - if (payload.type === "message.part.updated") { - const part = payload.properties.part - return `message.part.updated:${directory}:${part.messageID}:${part.id}` - } - } - - const flush = () => { - if (timer) clearTimeout(timer) - timer = undefined - - if (queue.length === 0) return - - const events = queue - const skip = staleDeltas.size > 0 ? new Set(staleDeltas) : undefined - queue = buffer - buffer = events - queue.length = 0 - coalesced.clear() - staleDeltas.clear() - - last = Date.now() - batch(() => { - for (const event of events) { - if (skip && event.payload.type === "message.part.delta") { - const props = event.payload.properties - if (skip.has(deltaKey(event.directory, props.messageID, props.partID))) continue - } - emitter.emit(event.directory, event.payload) - } - }) - - buffer.length = 0 - } - - const schedule = () => { - if (timer) return - const elapsed = Date.now() - last - timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed)) - } - - let streamErrorLogged = false - const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - const aborted = isAbortError - - let attempt: AbortController | undefined - let run: Promise | undefined - let started = false - const HEARTBEAT_TIMEOUT_MS = 15_000 - let lastEventAt = Date.now() - let heartbeat: ReturnType | undefined - const resetHeartbeat = () => { - lastEventAt = Date.now() - if (heartbeat) clearTimeout(heartbeat) - heartbeat = setTimeout(() => { - attempt?.abort() - }, HEARTBEAT_TIMEOUT_MS) - } - const clearHeartbeat = () => { - if (!heartbeat) return - clearTimeout(heartbeat) - heartbeat = undefined - } - - const start = () => { - if (started) return run - started = true - run = (async () => { - // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit - while (!abort.signal.aborted && started) { - attempt = new AbortController() - lastEventAt = Date.now() - const onAbort = () => { - attempt?.abort() - } - abort.signal.addEventListener("abort", onAbort) - try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (aborted(error)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: currentServer.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) - let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() - streamErrorLogged = false - const directory = event.directory ?? "global" - if (event.payload.type === "sync") { - continue - } - - const payload = event.payload as Event - - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = { directory, payload } - if (payload.type === "message.part.updated") { - const part = payload.properties.part - staleDeltas.add(deltaKey(directory, part.messageID, part.id)) - } - continue - } - coalesced.set(k, queue.length) - } - queue.push({ directory, payload }) - schedule() - - if (Date.now() - yielded < STREAM_YIELD_MS) continue - yielded = Date.now() - await wait(0) - } - } catch (error) { - if (!aborted(error) && !streamErrorLogged) { - streamErrorLogged = true - console.error("[global-sdk] event stream failed", { - url: currentServer.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - } - } finally { - abort.signal.removeEventListener("abort", onAbort) - attempt = undefined - clearHeartbeat() - } - - if (abort.signal.aborted || !started) return - await wait(RECONNECT_DELAY_MS) - } - })().finally(() => { - run = undefined - flush() - }) - return run - } - - const stop = () => { - started = false - attempt?.abort() - clearHeartbeat() - } - - onMount(() => { - makeEventListener(document, "visibilitychange", () => { - if (document.visibilityState !== "visible") return - if (!started) return - if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return - attempt?.abort() - }) - }) - - onCleanup(() => { - stop() - abort.abort() - flush() - }) - - const sdk = createSdkForServer({ - server: server.current.http, - fetch: platform.fetch, - throwOnError: true, - }) - - return { - url: currentServer.http.url, - client: sdk, - event: { - on: emitter.on.bind(emitter), - listen: emitter.listen.bind(emitter), - start, - }, - createClient(opts: Omit[0], "server" | "fetch">) { - const s = server.current - if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable")) - return createSdkForServer({ - server: s.http, - fetch: platform.fetch, - ...opts, - }) - }, - } - }, -}) From bf76f67126eee6c554aa9e5722b69abeb4d59110 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 03:54:35 +0530 Subject: [PATCH 02/32] fix(app): connect font size setting to CSS variables and terminal - Add createEffect that applies fontSize to CSS custom properties (--font-size-base, --font-size-small, --font-size-large) - Replace hardcoded fontSize: 14 in terminal with settings value - Add font size stepper control (10-24px) in Settings > Appearance --- .../app/src/components/settings-general.tsx | 802 ------------------ packages/app/src/components/terminal.tsx | 667 --------------- packages/app/src/context/settings.tsx | 8 + 3 files changed, 8 insertions(+), 1469 deletions(-) delete mode 100644 packages/app/src/components/settings-general.tsx delete mode 100644 packages/app/src/components/terminal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx deleted file mode 100644 index 535bd72064e2..000000000000 --- a/packages/app/src/components/settings-general.tsx +++ /dev/null @@ -1,802 +0,0 @@ -import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" -import { createStore } from "solid-js/store" -import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" -import { Select } from "@opencode-ai/ui/select" -import { Switch } from "@opencode-ai/ui/switch" -import { TextField } from "@opencode-ai/ui/text-field" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" -import { showToast } from "@opencode-ai/ui/toast" -import { useParams } from "@solidjs/router" -import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" -import { usePlatform, type DisplayBackend } from "@/context/platform" -import { useGlobalSync } from "@/context/global-sync" -import { useGlobalSDK } from "@/context/global-sdk" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - useSettings, -} from "@/context/settings" -import { decode64 } from "@/utils/base64" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { Link } from "./link" -import { SettingsList } from "./settings-list" - -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, -} - -type ThemeOption = { - id: string - name: string -} - -type ShellOption = { - path: string - name: string - acceptable: boolean -} - -type ShellSelectOption = { - id: string - value: string - label: string -} - -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined -} - -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return - - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) -} - -export const SettingsGeneral: Component = () => { - const theme = useTheme() - const language = useLanguage() - const permission = usePermission() - const platform = usePlatform() - const params = useParams() - const settings = useSettings() - - const [store, setStore] = createStore({ - checking: false, - }) - - const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") - const dir = createMemo(() => decode64(params.dir)) - const accepting = createMemo(() => { - const value = dir() - if (!value) return false - if (!params.id) return permission.isAutoAcceptingDirectory(value) - return permission.isAutoAccepting(params.id, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value) return - - if (!params.id) { - if (permission.isAutoAcceptingDirectory(value) === checked) return - permission.toggleAutoAcceptDirectory(value) - return - } - - if (checked) { - permission.enableAutoAccept(params.id, value) - return - } - - permission.disableAutoAccept(params.id, value) - } - const desktop = createMemo(() => platform.platform === "desktop") - - const check = () => { - if (!platform.checkUpdate) return - setStore("checking", true) - - void platform - .checkUpdate() - .then((result) => { - if (!result.updateAvailable) { - showToast({ - variant: "success", - icon: "circle-check", - title: language.t("settings.updates.toast.latest.title"), - description: language.t("settings.updates.toast.latest.description", { version: platform.version ?? "" }), - }) - return - } - - const actions = platform.updateAndRestart - ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.updateAndRestart!() - }, - }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - - showToast({ - persistent: true, - icon: "download", - title: language.t("toast.update.title"), - description: language.t("toast.update.description", { version: result.version ?? "" }), - actions, - }) - }) - .catch((err: unknown) => { - const message = err instanceof Error ? err.message : String(err) - showToast({ title: language.t("common.requestFailed"), description: message }) - }) - .finally(() => setStore("checking", false)) - } - - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - const globalSync = useGlobalSync() - const globalSdk = useGlobalSDK() - - const [shells] = createResource( - () => - globalSdk.client.pty - .shells() - .then((res) => res.data ?? []) - .catch(() => [] as ShellOption[]), - { initialValue: [] as ShellOption[] }, - ) - - const [displayBackend, { refetch: refetchDisplayBackend }] = createResource( - () => (linux() && platform.getDisplayBackend ? true : false), - () => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null), - { initialValue: null as DisplayBackend | null }, - ) - - onMount(() => { - void theme.loadThemes() - }) - - const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => globalSync.data.config.shell ?? "") - - const shellOptions = createMemo(() => { - const list = shells.latest - const current = globalSync.data.config.shell - - const nameCounts = new Map() - for (const s of list) { - nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) - } - - const options = [ - autoOption, - ...list.map((s) => { - const ambiguousName = (nameCounts.get(s.name) || 0) > 1 - const text = ambiguousName ? s.path : s.name - const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` - return { - id: s.path, - // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. - value: ambiguousName ? s.path : s.name, - label, - } - }), - ] - - if (current && !options.some((o) => o.value === current)) { - options.push({ id: current, value: current, label: current }) - } - - return options - }) - - const onDisplayBackendChange = (checked: boolean) => { - const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto") - if (!update) return - void update.finally(() => { - void refetchDisplayBackend() - }) - } - - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - - const languageOptions = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - - const noneSound = { id: "none", label: "sound.option.none" } as const - const soundOptions = [noneSound, ...SOUND_OPTIONS] - const mono = () => monoInput(settings.appearance.font()) - const sans = () => sansInput(settings.appearance.uiFont()) - const terminal = () => terminalInput(settings.appearance.terminalFont()) - - const soundSelectProps = ( - enabled: () => boolean, - current: () => string, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - options: soundOptions, - current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, - value: (o: (typeof soundOptions)[number]) => o.id, - label: (o: (typeof soundOptions)[number]) => language.t(o.label), - onHighlight: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) - }, - onSelect: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - variant: "secondary" as const, - size: "small" as const, - triggerVariant: "settings" as const, - }) - - const GeneralSection = () => ( -
- - - o.value === currentShell()) ?? autoOption} - value={(o) => o.id} - label={(o) => o.label} - onSelect={(option) => { - if (!option) return - if (option.value === currentShell()) return - globalSync.updateConfig({ shell: option.value }) - }} - variant="secondary" - size="small" - triggerVariant="settings" - triggerStyle={{ "min-width": "180px" }} - /> - - - -
- settings.general.setShowReasoningSummaries(checked)} - /> -
-
- - -
- settings.general.setShellToolPartsExpanded(checked)} - /> -
-
- - -
- settings.general.setEditToolPartsExpanded(checked)} - /> -
-
- - -
- settings.general.setShowSessionProgressBar(checked)} - /> -
-
-
-
- ) - - const AdvancedSection = () => ( -
-

{language.t("settings.general.section.advanced")}

- - - -
- settings.general.setShowFileTree(checked)} - /> -
-
- - -
- settings.general.setShowNavigation(checked)} - /> -
-
- - -
- settings.general.setShowSearch(checked)} - /> -
-
- - -
- settings.general.setShowTerminal(checked)} - /> -
-
- - -
- settings.general.setShowStatus(checked)} - /> -
-
-
-
- ) - - const AppearanceSection = () => ( -
-

{language.t("settings.general.section.appearance")}

- - - - o.id === theme.themeId())} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - onHighlight={(option) => { - if (!option) return - theme.previewTheme(option.id) - return () => theme.cancelPreview() - }} - variant="secondary" - size="small" - triggerVariant="settings" - /> - - - -
- settings.appearance.setUIFont(value)} - placeholder={sansDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - class="text-12-regular" - style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} - /> -
-
- - -
- settings.appearance.setFont(value)} - placeholder={monoDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - class="text-12-regular" - style={{ "font-family": monoFontFamily(settings.appearance.font()) }} - /> -
-
- - -
- settings.appearance.setTerminalFont(value)} - placeholder={terminalDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - class="text-12-regular" - style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} - /> -
-
-
-
- ) - - const NotificationsSection = () => ( -
-

{language.t("settings.general.section.notifications")}

- - - -
- settings.notifications.setAgent(checked)} - /> -
-
- - -
- settings.notifications.setPermissions(checked)} - /> -
-
- - -
- settings.notifications.setErrors(checked)} - /> -
-
-
-
- ) - - const SoundsSection = () => ( -
-

{language.t("settings.general.section.sounds")}

- - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - /> - - - - option && setStore("changes", option)} - variant="ghost" - size="small" - valueClass="text-14-medium" - /> - ) - } - - const empty = (text: string) => ( -
-
{text}
-
- ) - - const createGit = (input: { emptyClass: string }) => ( -
-
-
{language.t("session.review.noVcs.createGit.title")}
-
- {language.t("session.review.noVcs.createGit.description")} -
-
- -
- ) - - const reviewEmptyText = createMemo(() => { - if (store.changes === "git") return language.t("session.review.noUncommittedChanges") - if (store.changes === "branch") return language.t("session.review.noBranchChanges") - return language.t("session.review.noChanges") - }) - - const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => { - if (store.changes === "git" || store.changes === "branch") { - if (!reviewReady()) return
{language.t("session.review.loadingChanges")}
- return empty(reviewEmptyText()) - } - - if (store.changes === "turn") { - if (nogit()) return createGit(input) - return empty(reviewEmptyText()) - } - - return ( -
-
{reviewEmptyText()}
-
- ) - } - - const reviewContent = (input: { - diffStyle: DiffStyle - onDiffStyleChange?: (style: DiffStyle) => void - classes?: SessionReviewTabProps["classes"] - loadingClass: string - emptyClass: string - }) => ( - - setTree("reviewScroll", el)} - focusedFile={tree.activeDiff} - onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} - onLineCommentUpdate={updateCommentInContext} - onLineCommentDelete={removeCommentFromContext} - lineCommentActions={reviewCommentActions()} - commentMentions={{ - items: file.searchFilesAndDirectories, - }} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={openReviewFile} - classes={input.classes} - /> - - ) - - const reviewPanel = () => ( -
-
- {reviewContent({ - diffStyle: layout.review.diffStyle(), - onDiffStyleChange: layout.review.setDiffStyle, - loadingClass: "px-6 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - })} -
-
- ) - - createEffect( - on( - activeFileTab, - (active) => { - if (!active) return - if (fileTreeTab() !== "changes") return - showAllFiles() - }, - { defer: true }, - ), - ) - - const reviewDiffId = (path: string) => { - const sum = checksum(path) - if (!sum) return - return `session-review-diff-${sum}` - } - - const reviewDiffTop = (path: string) => { - const root = tree.reviewScroll - if (!root) return - - const id = reviewDiffId(path) - if (!id) return - - const el = document.getElementById(id) - if (!(el instanceof HTMLElement)) return - if (!root.contains(el)) return - - const a = el.getBoundingClientRect() - const b = root.getBoundingClientRect() - return a.top - b.top + root.scrollTop - } - - const scrollToReviewDiff = (path: string) => { - const root = tree.reviewScroll - if (!root) return false - - const top = reviewDiffTop(path) - if (top === undefined) return false - - view().setScroll("review", { x: root.scrollLeft, y: top }) - root.scrollTo({ top, behavior: "auto" }) - return true - } - - const focusReviewDiff = (path: string) => { - openReviewPanel() - view().review.openPath(path) - setTree({ activeDiff: path, pendingDiff: path }) - } - - createEffect(() => { - const pending = tree.pendingDiff - if (!pending) return - if (!tree.reviewScroll) return - if (!reviewReady()) return - - const attempt = (count: number) => { - if (tree.pendingDiff !== pending) return - if (count > 60) { - setTree("pendingDiff", undefined) - return - } - - const root = tree.reviewScroll - if (!root) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - - if (!scrollToReviewDiff(pending)) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - - const top = reviewDiffTop(pending) - if (top === undefined) { - requestAnimationFrame(() => attempt(count + 1)) - return - } - - if (Math.abs(root.scrollTop - top) <= 1) { - setTree("pendingDiff", undefined) - return - } - - requestAnimationFrame(() => attempt(count + 1)) - } - - requestAnimationFrame(() => attempt(0)) - }) - - createEffect(() => { - const id = params.id - if (!id) return - - if (!wantsReview()) return - if (sync.data.session_diff[id] !== undefined) return - if (sync.status === "loading") return - - void sync.session.diff(id) - }) - - createEffect( - on( - () => [sessionKey(), wantsReview()] as const, - ([key, wants]) => { - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - diffFrame = undefined - diffTimer = undefined - if (!wants) return - - const id = params.id - if (!id) return - if (!untrack(() => sync.data.session_diff[id] !== undefined)) return - - diffFrame = requestAnimationFrame(() => { - diffFrame = undefined - diffTimer = window.setTimeout(() => { - diffTimer = undefined - if (sessionKey() !== key) return - void sync.session.diff(id, { force: true }) - }, 0) - }) - }, - { defer: true }, - ), - ) - - let treeDir: string | undefined - createEffect(() => { - const dir = sdk.directory - if (!isDesktop()) return - if (!layout.fileTree.opened()) return - if (sync.status === "loading") return - - fileTreeTab() - const refresh = treeDir !== dir - treeDir = dir - void (refresh ? file.tree.refresh("") : file.tree.list("")) - }) - - createEffect( - on( - () => sdk.directory, - () => { - const tab = activeFileTab() - if (!tab) return - const path = file.pathFromTab(tab) - if (!path) return - void file.load(path, { force: true }) - }, - { defer: true }, - ), - ) - - const autoScroll = createAutoScroll({ - working: () => true, - overflowAnchor: "dynamic", - }) - - let scrollStateFrame: number | undefined - let scrollStateTarget: HTMLDivElement | undefined - let fillFrame: number | undefined - - const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight) - - const updateScrollState = (el: HTMLDivElement) => { - const max = el.scrollHeight - el.clientHeight - const distance = max - el.scrollTop - const overflow = max > 1 - const bottom = !overflow || distance <= 2 - const jump = overflow && distance > jumpThreshold(el) - - if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return - setUi("scroll", { overflow, bottom, jump }) - } - - const scheduleScrollState = (el: HTMLDivElement) => { - scrollStateTarget = el - if (scrollStateFrame !== undefined) return - - scrollStateFrame = requestAnimationFrame(() => { - scrollStateFrame = undefined - - const target = scrollStateTarget - scrollStateTarget = undefined - if (!target) return - - updateScrollState(target) - }) - } - - const resumeScroll = () => { - setStore("messageId", undefined) - autoScroll.forceScrollToBottom() - clearMessageHash() - - const el = scroller - if (el) scheduleScrollState(el) - } - - // When the user returns to the bottom, treat the active message as "latest". - createEffect( - on( - autoScroll.userScrolled, - (scrolled) => { - if (scrolled) return - setStore("messageId", undefined) - clearMessageHash() - }, - { defer: true }, - ), - ) - - let fill = () => {} - - const setScrollRef = (el: HTMLDivElement | undefined) => { - scroller = el - autoScroll.scrollRef(el) - if (!el) return - scheduleScrollState(el) - fill() - } - - const markUserScroll = () => { - scrollMark += 1 - } - - createResizeObserver( - () => content, - () => { - const el = scroller - if (el) scheduleScrollState(el) - fill() - }, - ) - - const historyWindow = createSessionHistoryWindow({ - sessionID: () => params.id, - messagesReady, - loaded: () => messages().length, - visibleUserMessages, - historyMore, - historyLoading, - loadMore: (sessionID) => sync.session.history.loadMore(sessionID), - userScrolled: autoScroll.userScrolled, - scroller: () => scroller, - }) - - fill = () => { - if (fillFrame !== undefined) return - - fillFrame = requestAnimationFrame(() => { - fillFrame = undefined - - if (!params.id || !messagesReady()) return - if (autoScroll.userScrolled() || historyLoading()) return - - const el = scroller - if (!el) return - if (el.scrollHeight > el.clientHeight + 1) return - if (historyWindow.turnStart() <= 0 && !historyMore()) return - - void historyWindow.loadAndReveal() - }) - } - - createEffect( - on( - () => - [ - params.id, - messagesReady(), - historyWindow.turnStart(), - historyMore(), - historyLoading(), - autoScroll.userScrolled(), - visibleUserMessages().length, - ] as const, - ([id, ready, start, more, loading, scrolled]) => { - if (!id || !ready || loading || scrolled) return - if (start <= 0 && !more) return - fill() - }, - { defer: true }, - ), - ) - - const draft = (id: string) => - extractPromptFromParts(sync.data.part[id] ?? [], { - directory: sdk.directory, - attachmentName: language.t("common.attachment"), - }) - - const line = (id: string) => { - const text = draft(id) - .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content)) - .join("") - .replace(/\s+/g, " ") - .trim() - if (text) return text - return `[${language.t("common.attachment")}]` - } - - const fail = (err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: formatServerError(err, language.t), - }) - } - - const merge = (next: NonNullable>) => - sync.set("session", (list) => { - const idx = list.findIndex((item) => item.id === next.id) - if (idx < 0) return list - const out = list.slice() - out[idx] = next - return out - }) - - const roll = (sessionID: string, next: NonNullable>["revert"]) => - sync.set("session", (list) => { - const idx = list.findIndex((item) => item.id === sessionID) - if (idx < 0) return list - const out = list.slice() - out[idx] = { ...out[idx], revert: next } - return out - }) - - const busy = (sessionID: string) => sync.data.session_working(sessionID) - - const queuedFollowups = createMemo(() => { - const id = params.id - if (!id) return emptyFollowups - return followup.items[id] ?? emptyFollowups - }) - - const editingFollowup = createMemo(() => { - const id = params.id - if (!id) return - return followup.edit[id] - }) - - const followupMutation = useMutation(() => ({ - mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { - const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) - if (!item) return - - if (input.manual) setFollowup("paused", input.sessionID, undefined) - setFollowup("failed", input.sessionID, undefined) - - const ok = await sendFollowupDraft({ - client: sdk.client, - sync, - globalSync, - draft: item, - optimisticBusy: item.sessionDirectory === sdk.directory, - }).catch((err) => { - setFollowup("failed", input.sessionID, input.id) - fail(err) - return false - }) - if (!ok) return - - setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id)) - if (input.manual) resumeScroll() - }, - })) - - const followupBusy = (sessionID: string) => - followupMutation.isPending && followupMutation.variables?.sessionID === sessionID - - const sendingFollowup = createMemo(() => { - const id = params.id - if (!id) return - if (!followupBusy(id)) return - return followupMutation.variables?.id - }) - - const queueEnabled = createMemo(() => { - const id = params.id - if (!id) return false - return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession() - }) - - const followupText = (item: FollowupDraft) => { - const text = item.prompt - .map((part) => { - if (part.type === "image") return `[image:${part.filename}]` - if (part.type === "file") return `[file:${part.path}]` - if (part.type === "agent") return `@${part.name}` - return part.content - }) - .join("") - .split(/\r?\n/) - .map((line) => line.trim()) - .find((line) => !!line) - - if (text) return text - return `[${language.t("common.attachment")}]` - } - - const queueFollowup = (draft: FollowupDraft) => { - setFollowup("items", draft.sessionID, (items) => [ - ...(items ?? []), - { id: Identifier.ascending("message"), ...draft }, - ]) - setFollowup("failed", draft.sessionID, undefined) - setFollowup("paused", draft.sessionID, undefined) - } - - const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) }))) - - const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { - if (sync.session.get(sessionID)?.parentID) return Promise.resolve() - const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) - if (!item) return Promise.resolve() - if (followupBusy(sessionID)) return Promise.resolve() - - return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual }) - } - - const editFollowup = (id: string) => { - const sessionID = params.id - if (!sessionID) return - if (followupBusy(sessionID)) return - - const item = queuedFollowups().find((entry) => entry.id === id) - if (!item) return - - setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id)) - setFollowup("failed", sessionID, (value) => (value === id ? undefined : value)) - setFollowup("edit", sessionID, { - id: item.id, - prompt: item.prompt, - context: item.context, - }) - } - - const clearFollowupEdit = () => { - const id = params.id - if (!id) return - setFollowup("edit", id, undefined) - } - - const halt = (sessionID: string) => - busy(sessionID) ? sdk.client.session.abort({ sessionID }).catch(() => {}) : Promise.resolve() - - const revertMutation = useMutation(() => ({ - mutationFn: async (input: { sessionID: string; messageID: string }) => { - const prev = prompt.current().slice() - const last = info()?.revert - const value = draft(input.messageID) - batch(() => { - roll(input.sessionID, { messageID: input.messageID }) - prompt.set(value) - }) - await halt(input.sessionID) - .then(() => sdk.client.session.revert(input)) - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(input.sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - }, - })) - - const restoreMutation = useMutation(() => ({ - mutationFn: async (id: string) => { - const sessionID = params.id - if (!sessionID) return - - const next = userMessages().find((item) => item.id > id) - const prev = prompt.current().slice() - const last = info()?.revert - - batch(() => { - roll(sessionID, next ? { messageID: next.id } : undefined) - if (next) { - prompt.set(draft(next.id)) - return - } - prompt.reset() - }) - - const task = !next - ? halt(sessionID).then(() => sdk.client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => - sdk.client.session.revert({ - sessionID, - messageID: next.id, - }), - ) - - await task - .then((result) => { - if (result.data) merge(result.data) - }) - .catch((err) => { - batch(() => { - roll(sessionID, last) - prompt.set(prev) - }) - fail(err) - }) - }, - })) - - const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) - const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) - - const revert = (input: { sessionID: string; messageID: string }) => { - if (reverting()) return - return revertMutation.mutateAsync(input) - } - - const restore = (id: string) => { - if (!params.id || reverting()) return - return restoreMutation.mutateAsync(id) - } - - const rolled = createMemo(() => { - const id = revertMessageID() - if (!id) return [] - return userMessages() - .filter((item) => item.id >= id) - .map((item) => ({ id: item.id, text: line(item.id) })) - }) - - const actions = { revert } - - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - - const item = queuedFollowups()[0] - if (!item) return - if (followupBusy(sessionID)) return - if (followup.failed[sessionID] === item.id) return - if (followup.paused[sessionID]) return - if (isChildSession()) return - if (composer.blocked()) return - if (busy(sessionID)) return - - void sendFollowup(sessionID, item.id) - }) - - createResizeObserver( - () => promptDock, - ({ height }) => { - const next = Math.ceil(height) - - if (next === dockHeight) return - - const el = scroller - const delta = next - dockHeight - const stick = el - ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta) - : false - - dockHeight = next - - if (stick) autoScroll.forceScrollToBottom() - - if (el) scheduleScrollState(el) - fill() - }, - ) - - const { clearMessageHash, scrollToMessage } = useSessionHashScroll({ - sessionKey, - sessionID: () => params.id, - messagesReady, - visibleUserMessages, - historyMore, - historyLoading, - loadMore: (sessionID) => sync.session.history.loadMore(sessionID), - turnStart: historyWindow.turnStart, - currentMessageId: () => store.messageId, - pendingMessage: () => ui.pendingMessage, - setPendingMessage: (value) => setUi("pendingMessage", value), - setActiveMessage, - setTurnStart: historyWindow.setTurnStart, - autoScroll, - scroller: () => scroller, - anchor, - scheduleScrollState, - consumePendingMessage: layout.pendingMessage.consume, - }) - - createEffect( - on( - () => params.id, - (id) => { - if (!id) requestAnimationFrame(() => inputRef?.focus()) - }, - ), - ) - - onMount(() => { - makeEventListener(document, "keydown", handleKeyDown) - }) - - onCleanup(() => { - if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) - if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) - if (todoFrame !== undefined) cancelAnimationFrame(todoFrame) - if (todoTimer !== undefined) window.clearTimeout(todoTimer) - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) - if (fillFrame !== undefined) cancelAnimationFrame(fillFrame) - }) - - return ( -
- {sessionSync() ?? ""} - -
- - - - setStore("mobileTab", "session")} - > - {language.t("session.tab.session")} - - setStore("mobileTab", "changes")} - > - {hasReview() - ? language.t("session.review.filesChanged", { count: reviewCount() }) - : language.t("session.review.change.other")} - - - - - - {/* Session panel */} -
-
- - - - { - 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} - /> - - - - - - -
- - { - inputRef = el - }} - newSessionWorktree={newSessionWorktree()} - onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")} - onSubmit={() => { - comments.clear() - resumeScroll() - }} - onResponseSubmit={resumeScroll} - followup={ - params.id && !isChildSession() - ? { - queue: queueEnabled, - items: followupDock(), - sending: sendingFollowup(), - edit: editingFollowup(), - onQueue: queueFollowup, - onAbort: () => { - const id = params.id - if (!id) return - setFollowup("paused", id, true) - }, - onSend: (id) => { - void sendFollowup(params.id!, id, { manual: true }) - }, - onEdit: editFollowup, - onEditLoaded: clearFollowupEdit, - } - : undefined - } - revert={ - rolled().length > 0 - ? { - items: rolled(), - restoring: restoring(), - disabled: reverting(), - onRestore: restore, - } - : undefined - } - setPromptDockRef={(el) => { - promptDock = el - }} - /> - - -
size.start()}> - { - size.touch() - layout.session.resize(width) - }} - /> -
-
-
- - -
- - -
- ) -} From f02f29dd63084dc53f660fab8c2890aff6e614c7 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 05:38:49 +0530 Subject: [PATCH 07/32] fix(app): fix cursor position after pasting multi-line text Remove document.execCommand('insertText') which creates DIV block elements for multi-line paste. getCursorPosition doesn't count the implicit newlines at block boundaries, causing cursor to land in the middle of pasted text after re-render. Now always uses addPart() which inserts via createTextFragment (text nodes + BR tags, no block elements), ensuring correct cursor positioning. Also removes deprecated execCommand API usage. --- .../components/prompt-input/attachments.ts | 196 ------------------ 1 file changed, 196 deletions(-) delete mode 100644 packages/app/src/components/prompt-input/attachments.ts diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts deleted file mode 100644 index f12a4210c082..000000000000 --- a/packages/app/src/components/prompt-input/attachments.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { onMount } from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { showToast } from "@opencode-ai/ui/toast" -import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" -import { useLanguage } from "@/context/language" -import { uuid } from "@/utils/uuid" -import { getCursorPosition } from "./editor-dom" -import { attachmentMime } from "./files" -import { normalizePaste, pasteMode } from "./paste" - -function dataUrl(file: File, mime: string) { - return new Promise((resolve) => { - const reader = new FileReader() - reader.addEventListener("error", () => resolve("")) - reader.addEventListener("load", () => { - const value = typeof reader.result === "string" ? reader.result : "" - const idx = value.indexOf(",") - if (idx === -1) { - resolve(value) - return - } - resolve(`data:${mime};base64,${value.slice(idx + 1)}`) - }) - reader.readAsDataURL(file) - }) -} - -type PromptAttachmentsInput = { - editor: () => HTMLDivElement | undefined - isDialogActive: () => boolean - setDraggingType: (type: "image" | "@mention" | null) => void - focusEditor: () => void - addPart: (part: ContentPart) => boolean - readClipboardImage?: () => Promise -} - -export function createPromptAttachments(input: PromptAttachmentsInput) { - const prompt = usePrompt() - const language = useLanguage() - - const warn = () => { - showToast({ - title: language.t("prompt.toast.pasteUnsupported.title"), - description: language.t("prompt.toast.pasteUnsupported.description"), - }) - } - - const add = async (file: File, toast = true) => { - const mime = await attachmentMime(file) - if (!mime) { - if (toast) warn() - return false - } - - const editor = input.editor() - if (!editor) return false - - const url = await dataUrl(file, mime) - if (!url) return false - - const attachment: ImageAttachmentPart = { - type: "image", - id: uuid(), - filename: file.name, - mime, - dataUrl: url, - } - const cursor = prompt.cursor() ?? getCursorPosition(editor) - prompt.set([...prompt.current(), attachment], cursor) - return true - } - - const addAttachment = (file: File) => add(file) - - const addAttachments = async (files: File[], toast = true) => { - let found = false - - for (const file of files) { - const ok = await add(file, false) - if (ok) found = true - } - - if (!found && files.length > 0 && toast) warn() - return found - } - - const removeAttachment = (id: string) => { - const current = prompt.current() - const next = current.filter((part) => part.type !== "image" || part.id !== id) - prompt.set(next, prompt.cursor()) - } - - const handlePaste = async (event: ClipboardEvent) => { - const clipboardData = event.clipboardData - if (!clipboardData) return - - event.preventDefault() - event.stopPropagation() - - const files = Array.from(clipboardData.items).flatMap((item) => { - if (item.kind !== "file") return [] - const file = item.getAsFile() - return file ? [file] : [] - }) - - if (files.length > 0) { - await addAttachments(files) - return - } - - const plainText = clipboardData.getData("text/plain") ?? "" - - // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images - if (input.readClipboardImage && !plainText) { - const file = await input.readClipboardImage() - if (file) { - await addAttachment(file) - return - } - } - - if (!plainText) return - - const text = normalizePaste(plainText) - - const put = () => { - if (input.addPart({ type: "text", content: text, start: 0, end: 0 })) return true - input.focusEditor() - return input.addPart({ type: "text", content: text, start: 0, end: 0 }) - } - - if (pasteMode(text) === "manual") { - put() - return - } - - const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, text) - if (inserted) return - - put() - } - - const handleGlobalDragOver = (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - const hasFiles = event.dataTransfer?.types.includes("Files") - const hasText = event.dataTransfer?.types.includes("text/plain") - if (hasFiles) { - input.setDraggingType("image") - } else if (hasText) { - input.setDraggingType("@mention") - } - } - - const handleGlobalDragLeave = (event: DragEvent) => { - if (input.isDialogActive()) return - if (!event.relatedTarget) { - input.setDraggingType(null) - } - } - - const handleGlobalDrop = async (event: DragEvent) => { - if (input.isDialogActive()) return - - event.preventDefault() - input.setDraggingType(null) - - const plainText = event.dataTransfer?.getData("text/plain") - const filePrefix = "file:" - if (plainText?.startsWith(filePrefix)) { - const filePath = plainText.slice(filePrefix.length) - input.focusEditor() - input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) - return - } - - const dropped = event.dataTransfer?.files - if (!dropped) return - - await addAttachments(Array.from(dropped)) - } - - onMount(() => { - makeEventListener(document, "dragover", handleGlobalDragOver) - makeEventListener(document, "dragleave", handleGlobalDragLeave) - makeEventListener(document, "drop", handleGlobalDrop) - }) - - return { - addAttachment, - addAttachments, - removeAttachment, - handlePaste, - } -} From 208683f9dce3730dfac3c85bd0b49f97e219f3a7 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 06:09:58 +0530 Subject: [PATCH 08/32] feat(app): require double-tap Escape to cancel AI response Single Escape during streaming now shows a toast warning instead of immediately aborting. User must press Escape again within 500ms to confirm cancellation. Prevents accidental interruption of AI responses. Ctrl+G still provides immediate cancel for power users. --- packages/app/src/components/prompt-input.tsx | 1615 ------------------ 1 file changed, 1615 deletions(-) delete mode 100644 packages/app/src/components/prompt-input.tsx diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx deleted file mode 100644 index 1e1be28b5961..000000000000 --- a/packages/app/src/components/prompt-input.tsx +++ /dev/null @@ -1,1615 +0,0 @@ -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js" -import { createStore } from "solid-js/store" -import { useLocal } from "@/context/local" -import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file" -import { - ContentPart, - DEFAULT_PROMPT, - isPromptEqual, - Prompt, - usePrompt, - ImageAttachmentPart, - AgentPart, - FileAttachmentPart, -} from "@/context/prompt" -import { useLayout } from "@/context/layout" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { useComments } from "@/context/comments" -import { Button } from "@opencode-ai/ui/button" -import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" -import { Icon } from "@opencode-ai/ui/icon" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Select } from "@opencode-ai/ui/select" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { ModelSelectorPopover } from "@/components/dialog-select-model" -import { useProviders } from "@/hooks/use-providers" -import { useCommand } from "@/context/command" -import { Persist, persisted } from "@/utils/persist" -import { usePermission } from "@/context/permission" -import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" -import { useSessionLayout } from "@/pages/session/session-layout" -import { createSessionTabs } from "@/pages/session/helpers" -import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" -import { createPromptAttachments } from "./prompt-input/attachments" -import { ACCEPTED_FILE_TYPES } from "./prompt-input/files" -import { - canNavigateHistoryAtCursor, - navigatePromptHistory, - prependHistoryEntry, - type PromptHistoryComment, - type PromptHistoryEntry, - type PromptHistoryStoredEntry, - promptLength, -} from "./prompt-input/history" -import { createPromptSubmit, type FollowupDraft } from "./prompt-input/submit" -import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" -import { PromptContextItems } from "./prompt-input/context-items" -import { PromptImageAttachments } from "./prompt-input/image-attachments" -import { PromptDragOverlay } from "./prompt-input/drag-overlay" -import { promptPlaceholder } from "./prompt-input/placeholder" -import { ImagePreview } from "@opencode-ai/ui/image-preview" -import { useQueries } from "@tanstack/solid-query" -import { useQueryOptions } from "@/context/global-sync" -import { pathKey } from "@/utils/path-key" - -interface PromptInputProps { - class?: string - ref?: (el: HTMLDivElement) => void - newSessionWorktree?: string - onNewSessionWorktreeReset?: () => void - edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] } - onEditLoaded?: () => void - shouldQueue?: () => boolean - onQueue?: (draft: FollowupDraft) => void - onAbort?: () => void - onSubmit?: () => void -} - -const EXAMPLES = [ - "prompt.example.1", - "prompt.example.2", - "prompt.example.3", - "prompt.example.4", - "prompt.example.5", - "prompt.example.6", - "prompt.example.7", - "prompt.example.8", - "prompt.example.9", - "prompt.example.10", - "prompt.example.11", - "prompt.example.12", - "prompt.example.13", - "prompt.example.14", - "prompt.example.15", - "prompt.example.16", - "prompt.example.17", - "prompt.example.18", - "prompt.example.19", - "prompt.example.20", - "prompt.example.21", - "prompt.example.22", - "prompt.example.23", - "prompt.example.24", - "prompt.example.25", -] as const - -const NON_EMPTY_TEXT = /[^\s\u200B]/ - -export const PromptInput: Component = (props) => { - const sdk = useSDK() - const queryOptions = useQueryOptions() - - const sync = useSync() - const local = useLocal() - const files = useFile() - const prompt = usePrompt() - const layout = useLayout() - const comments = useComments() - const dialog = useDialog() - const providers = useProviders() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const { params, tabs, view } = useSessionLayout() - let editorRef!: HTMLDivElement - let fileInputRef: HTMLInputElement | undefined - let scrollRef!: HTMLDivElement - let slashPopoverRef!: HTMLDivElement - - const mirror = { input: false } - const inset = 56 - const space = `${inset}px` - - const scrollCursorIntoView = () => { - const container = scrollRef - const selection = window.getSelection() - if (!container || !selection || selection.rangeCount === 0) return - - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return - - const cursor = getCursorPosition(editorRef) - const length = promptLength(prompt.current().filter((part) => part.type !== "image")) - if (cursor >= length) { - container.scrollTop = container.scrollHeight - return - } - - const rect = range.getClientRects().item(0) ?? range.getBoundingClientRect() - if (!rect.height) return - - const containerRect = container.getBoundingClientRect() - const top = rect.top - containerRect.top + container.scrollTop - const bottom = rect.bottom - containerRect.top + container.scrollTop - const padding = 12 - - if (top < container.scrollTop + padding) { - container.scrollTop = Math.max(0, top - padding) - return - } - - if (bottom > container.scrollTop + container.clientHeight - inset) { - container.scrollTop = bottom - container.clientHeight + inset - } - } - - const queueScroll = (count = 2) => { - requestAnimationFrame(() => { - scrollCursorIntoView() - if (count > 1) queueScroll(count - 1) - }) - } - - const activeFileTab = createSessionTabs({ - tabs, - pathFromTab: files.pathFromTab, - normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), - }).activeFileTab - - const commentInReview = (path: string) => { - const sessionID = params.id - if (!sessionID) return false - - const diffs = sync.data.session_diff[sessionID] - if (!diffs) return false - return diffs.some((diff) => diff.file === path) - } - - const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => { - if (!item.commentID) return - - const focus = { file: item.path, id: item.commentID } - comments.setActive(focus) - - const queueCommentFocus = (attempts = 6) => { - const schedule = (left: number) => { - requestAnimationFrame(() => { - comments.setFocus({ ...focus }) - if (left <= 0) return - requestAnimationFrame(() => { - const current = comments.focus() - if (!current) return - if (current.file !== focus.file || current.id !== focus.id) return - schedule(left - 1) - }) - }) - } - - schedule(attempts) - } - - const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) - if (wantsReview) { - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("changes") - tabs().setActive("review") - queueCommentFocus() - return - } - - if (!view().reviewPanel.opened()) view().reviewPanel.open() - layout.fileTree.setTab("all") - const tab = files.tab(item.path) - void tabs().open(tab) - tabs().setActive(tab) - void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) - } - - const recent = createMemo(() => { - const all = tabs().all() - const active = activeFileTab() - const order = active ? [active, ...all.filter((x) => x !== active)] : all - const seen = new Set() - const paths: string[] = [] - - for (const tab of order) { - const path = files.pathFromTab(tab) - if (!path) continue - if (seen.has(path)) continue - seen.add(path) - paths.push(path) - } - - return paths - }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const working = createMemo(() => sync.data.session_working(params.id ?? "")) - const imageAttachments = createMemo(() => - prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"), - ) - - const [store, setStore] = createStore<{ - popover: "at" | "slash" | null - historyIndex: number - savedPrompt: PromptHistoryEntry | null - placeholder: number - draggingType: "image" | "@mention" | null - mode: "normal" | "shell" - applyingHistory: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - }) - - const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) - const motion = (value: number) => ({ - opacity: value, - transform: `scale(${0.98 + value * 0.02})`, - filter: `blur(${(1 - value) * 2}px)`, - "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), - }) - const buttons = createMemo(() => motion(buttonsSpring())) - const shell = createMemo(() => motion(1 - buttonsSpring())) - const control = createMemo(() => ({ height: "28px", ...buttons() })) - - const commentCount = createMemo(() => { - if (store.mode === "shell") return 0 - return prompt.context.items().filter((item) => !!item.comment?.trim()).length - }) - const blank = createMemo(() => { - const text = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0 - }) - const stopping = createMemo(() => working() && blank()) - const tip = () => { - if (stopping()) { - return ( -
- {language.t("prompt.action.stop")} - {language.t("common.key.esc")} -
- ) - } - - return ( -
- {language.t("prompt.action.send")} - -
- ) - } - - const contextItems = createMemo(() => { - const items = prompt.context.items() - if (store.mode !== "shell") return items - return items.filter((item) => !item.comment?.trim()) - }) - - const hasUserPrompt = createMemo(() => { - const sessionID = params.id - if (!sessionID) return false - const messages = sync.data.message[sessionID] - if (!messages) return false - return messages.some((m) => m.role === "user") - }) - - const [history, setHistory] = persisted( - Persist.global("prompt-history", ["prompt-history.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - const [shellHistory, setShellHistory] = persisted( - Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]), - createStore<{ - entries: PromptHistoryStoredEntry[] - }>({ - entries: [], - }), - ) - - const suggest = createMemo(() => !hasUserPrompt()) - - const placeholder = createMemo(() => - promptPlaceholder({ - mode: store.mode, - commentCount: commentCount(), - example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", - suggest: suggest(), - t: (key, params) => language.t(key as Parameters[0], params as never), - }), - ) - - const historyComments = () => { - const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) - return prompt.context.items().flatMap((item) => { - if (item.type !== "file") return [] - const comment = item.comment?.trim() - if (!comment) return [] - - const selection = item.commentID ? byID.get(`${item.path}\n${item.commentID}`)?.selection : undefined - const nextSelection = - selection ?? - (item.selection - ? ({ - start: item.selection.startLine, - end: item.selection.endLine, - } satisfies SelectedLineRange) - : undefined) - if (!nextSelection) return [] - - return [ - { - id: item.commentID ?? item.key, - path: item.path, - selection: { ...nextSelection }, - comment, - time: item.commentID ? (byID.get(`${item.path}\n${item.commentID}`)?.time ?? Date.now()) : Date.now(), - origin: item.commentOrigin, - preview: item.preview, - } satisfies PromptHistoryComment, - ] - }) - } - - const applyHistoryComments = (items: PromptHistoryComment[]) => { - comments.replace( - items.map((item) => ({ - id: item.id, - file: item.path, - selection: { ...item.selection }, - comment: item.comment, - time: item.time, - })), - ) - prompt.context.replaceComments( - items.map((item) => ({ - type: "file" as const, - path: item.path, - selection: selectionFromLines(item.selection), - comment: item.comment, - commentID: item.id, - commentOrigin: item.origin, - preview: item.preview, - })), - ) - } - - const applyHistoryPrompt = (entry: PromptHistoryEntry, position: "start" | "end") => { - const p = entry.prompt - const length = position === "start" ? 0 : promptLength(p) - setStore("applyingHistory", true) - applyHistoryComments(entry.comments) - prompt.set(p, length) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, length) - setStore("applyingHistory", false) - queueScroll() - }) - } - - const getCaretState = () => { - const selection = window.getSelection() - const textLength = promptLength(prompt.current()) - if (!selection || selection.rangeCount === 0) { - return { collapsed: false, cursorPosition: 0, textLength } - } - const anchorNode = selection.anchorNode - if (!anchorNode || !editorRef.contains(anchorNode)) { - return { collapsed: false, cursorPosition: 0, textLength } - } - return { - collapsed: selection.isCollapsed, - cursorPosition: getCursorPosition(editorRef), - textLength, - } - } - - const escBlur = () => platform.platform === "desktop" && platform.os === "macos" - - const pick = () => fileInputRef?.click() - - const setMode = (mode: "normal" | "shell") => { - setStore("mode", mode) - setStore("popover", null) - requestAnimationFrame(() => editorRef?.focus()) - } - - const shellModeKey = "mod+shift+x" - const normalModeKey = "mod+shift+e" - - command.register("prompt-input", () => [ - { - id: "file.attach", - title: language.t("prompt.action.attachFile"), - category: language.t("command.category.file"), - keybind: "mod+u", - disabled: store.mode !== "normal", - onSelect: pick, - }, - { - id: "prompt.mode.shell", - title: language.t("command.prompt.mode.shell"), - category: language.t("command.category.session"), - keybind: shellModeKey, - disabled: store.mode === "shell", - onSelect: () => setMode("shell"), - }, - { - id: "prompt.mode.normal", - title: language.t("command.prompt.mode.normal"), - category: language.t("command.category.session"), - keybind: normalModeKey, - disabled: store.mode === "normal", - onSelect: () => setMode("normal"), - }, - ]) - - const closePopover = () => setStore("popover", null) - - const resetHistoryNavigation = (force = false) => { - if (!force && (store.historyIndex < 0 || store.applyingHistory)) return - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } - - const clearEditor = () => { - editorRef.innerHTML = "" - } - - const setEditorText = (text: string) => { - clearEditor() - editorRef.textContent = text - } - - const focusEditorEnd = () => { - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const selection = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - selection?.removeAllRanges() - selection?.addRange(range) - }) - } - - const currentCursor = () => { - const selection = window.getSelection() - if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null - return getCursorPosition(editorRef) - } - - const restoreFocus = () => { - requestAnimationFrame(() => { - const cursor = prompt.cursor() ?? promptLength(prompt.current()) - editorRef.focus() - setCursorPosition(editorRef, cursor) - queueScroll() - }) - } - - const renderEditorWithCursor = (parts: Prompt) => { - const cursor = currentCursor() - renderEditor(parts) - if (cursor !== null) setCursorPosition(editorRef, cursor) - } - - createEffect(() => { - params.id - if (params.id) return - if (!suggest()) return - const interval = setInterval(() => { - setStore("placeholder", (prev) => (prev + 1) % EXAMPLES.length) - }, 6500) - onCleanup(() => clearInterval(interval)) - }) - - const [composing, setComposing] = createSignal(false) - const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 - - const handleBlur = () => { - closePopover() - setComposing(false) - } - - const handleCompositionStart = () => { - setComposing(true) - } - - const handleCompositionEnd = () => { - setComposing(false) - requestAnimationFrame(() => { - if (composing()) return - reconcile(prompt.current().filter((part) => part.type !== "image")) - }) - } - - const agentList = createMemo(() => - sync.data.agent - .filter((agent) => !agent.hidden && agent.mode !== "primary") - .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), - ) - const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name)) - - const handleAtSelect = (option: AtOption | undefined) => { - if (!option) return - if (option.type === "agent") { - addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 }) - } else { - addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 }) - } - } - - const atKey = (x: AtOption | undefined) => { - if (!x) return "" - return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}` - } - - const { - flat: atFlat, - active: atActive, - setActive: setAtActive, - onInput: atOnInput, - onKeyDown: atOnKeyDown, - } = useFilteredList({ - items: async (query) => { - const agents = agentList() - const open = recent() - const seen = new Set(open) - const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] - const paths = await files.searchFilesAndDirectories(query) - const fileOptions: AtOption[] = paths - .filter((path) => !seen.has(path)) - .map((path) => ({ type: "file", path, display: path })) - return [...agents, ...pinned, ...fileOptions] - }, - key: atKey, - filterKeys: ["display"], - groupBy: (item) => { - if (item.type === "agent") return "agent" - if (item.recent) return "recent" - return "file" - }, - sortGroupsBy: (a, b) => { - const rank = (category: string) => { - if (category === "agent") return 0 - if (category === "recent") return 1 - return 2 - } - return rank(a.category) - rank(b.category) - }, - onSelect: handleAtSelect, - }) - - const slashCommands = createMemo(() => { - const builtin = command.options - .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash) - .map((opt) => ({ - id: opt.id, - trigger: opt.slash!, - title: opt.title, - description: opt.description, - keybind: opt.keybind, - type: "builtin" as const, - })) - - const custom = sync.data.command.map((cmd) => ({ - id: `custom.${cmd.name}`, - trigger: cmd.name, - title: cmd.name, - description: cmd.description, - type: "custom" as const, - source: cmd.source, - })) - - return [...custom, ...builtin] - }) - - const handleSlashSelect = (cmd: SlashCommand | undefined) => { - if (!cmd) return - closePopover() - const images = imageAttachments() - - if (cmd.type === "custom") { - const text = `/${cmd.trigger} ` - setEditorText(text) - prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) - focusEditorEnd() - return - } - - clearEditor() - prompt.set([...DEFAULT_PROMPT, ...images], 0) - command.trigger(cmd.id, "slash") - } - - const { - flat: slashFlat, - active: slashActive, - setActive: setSlashActive, - onInput: slashOnInput, - onKeyDown: slashOnKeyDown, - } = useFilteredList({ - items: slashCommands, - key: (x) => x?.id, - filterKeys: ["trigger", "title"], - onSelect: handleSlashSelect, - }) - - const createPill = (part: FileAttachmentPart | AgentPart) => { - const pill = document.createElement("span") - pill.textContent = part.content - pill.setAttribute("data-type", part.type) - if (part.type === "file") pill.setAttribute("data-path", part.path) - if (part.type === "agent") pill.setAttribute("data-name", part.name) - pill.setAttribute("contenteditable", "false") - pill.style.userSelect = "text" - pill.style.cursor = "default" - return pill - } - - const isNormalizedEditor = () => - Array.from(editorRef.childNodes).every((node) => { - if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (!text.includes("\u200B")) return true - if (text !== "\u200B") return false - - const prev = node.previousSibling - const next = node.nextSibling - const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" - return !!prevIsBr && !next - } - if (node.nodeType !== Node.ELEMENT_NODE) return false - const el = node as HTMLElement - if (el.dataset.type === "file") return true - if (el.dataset.type === "agent") return true - return el.tagName === "BR" - }) - - const renderEditor = (parts: Prompt) => { - clearEditor() - for (const part of parts) { - if (part.type === "text") { - editorRef.appendChild(createTextFragment(part.content)) - continue - } - if (part.type === "file" || part.type === "agent") { - editorRef.appendChild(createPill(part)) - } - } - - const last = editorRef.lastChild - if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { - editorRef.appendChild(document.createTextNode("\u200B")) - } - } - - // Auto-scroll active command into view when navigating with keyboard - createEffect(() => { - const activeId = slashActive() - if (!activeId || !slashPopoverRef) return - - requestAnimationFrame(() => { - const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`) - element?.scrollIntoView({ block: "nearest", behavior: "smooth" }) - }) - }) - const selectPopoverActive = () => { - if (store.popover === "at") { - const items = atFlat() - if (items.length === 0) return - const active = atActive() - const item = items.find((entry) => atKey(entry) === active) ?? items[0] - handleAtSelect(item) - return - } - - if (store.popover === "slash") { - const items = slashFlat() - if (items.length === 0) return - const active = slashActive() - const item = items.find((entry) => entry.id === active) ?? items[0] - handleSlashSelect(item) - } - } - - const reconcile = (input: Prompt) => { - if (mirror.input) { - mirror.input = false - if (isNormalizedEditor()) return - - renderEditorWithCursor(input) - return - } - - const dom = parseFromDOM() - if (isNormalizedEditor() && isPromptEqual(input, dom)) return - - renderEditorWithCursor(input) - } - - createEffect( - on( - () => prompt.current(), - (parts) => { - if (composing()) return - reconcile(parts.filter((part) => part.type !== "image")) - }, - ), - ) - - const parseFromDOM = (): Prompt => { - const parts: Prompt = [] - let position = 0 - let buffer = "" - - const flushText = () => { - let content = buffer - if (content.includes("\r")) content = content.replace(/\r\n?/g, "\n") - if (content.includes("\u200B")) content = content.replace(/\u200B/g, "") - buffer = "" - if (!content) return - parts.push({ type: "text", content, start: position, end: position + content.length }) - position += content.length - } - - const pushFile = (file: HTMLElement) => { - const content = file.textContent ?? "" - parts.push({ - type: "file", - path: file.dataset.path!, - content, - start: position, - end: position + content.length, - }) - position += content.length - } - - const pushAgent = (agent: HTMLElement) => { - const content = agent.textContent ?? "" - parts.push({ - type: "agent", - name: agent.dataset.name!, - content, - start: position, - end: position + content.length, - }) - position += content.length - } - - const visit = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - buffer += node.textContent ?? "" - return - } - if (node.nodeType !== Node.ELEMENT_NODE) return - - const el = node as HTMLElement - if (el.dataset.type === "file") { - flushText() - pushFile(el) - return - } - if (el.dataset.type === "agent") { - flushText() - pushAgent(el) - return - } - if (el.tagName === "BR") { - buffer += "\n" - return - } - - for (const child of Array.from(el.childNodes)) { - visit(child) - } - } - - const children = Array.from(editorRef.childNodes) - children.forEach((child, index) => { - const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName) - visit(child) - if (isBlock && index < children.length - 1) { - buffer += "\n" - } - }) - - flushText() - - if (parts.length === 0) parts.push(...DEFAULT_PROMPT) - return parts - } - - const handleInput = () => { - const rawParts = parseFromDOM() - const images = imageAttachments() - const cursorPosition = getCursorPosition(editorRef) - const rawText = - rawParts.length === 1 && rawParts[0]?.type === "text" - ? rawParts[0].content - : rawParts.map((p) => ("content" in p ? p.content : "")).join("") - const hasNonText = rawParts.some((part) => part.type !== "text") - const shouldReset = !NON_EMPTY_TEXT.test(rawText) && !hasNonText && images.length === 0 - - if (shouldReset) { - closePopover() - resetHistoryNavigation() - if (prompt.dirty()) { - mirror.input = true - prompt.set(DEFAULT_PROMPT, 0) - } - queueScroll() - return - } - - const shellMode = store.mode === "shell" - - if (!shellMode) { - const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/) - const slashMatch = rawText.match(/^\/(\S*)$/) - - if (atMatch) { - atOnInput(atMatch[1]) - setStore("popover", "at") - } else if (slashMatch) { - slashOnInput(slashMatch[1]) - setStore("popover", "slash") - } else { - closePopover() - } - } else { - closePopover() - } - - resetHistoryNavigation() - - mirror.input = true - prompt.set([...rawParts, ...images], cursorPosition) - queueScroll() - } - - const addPart = (part: ContentPart) => { - if (part.type === "image") return false - - const selection = window.getSelection() - if (!selection) return false - - if (selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) { - editorRef.focus() - const cursor = prompt.cursor() ?? promptLength(prompt.current()) - setCursorPosition(editorRef, cursor) - } - - if (selection.rangeCount === 0) return false - const range = selection.getRangeAt(0) - if (!editorRef.contains(range.startContainer)) return false - - if (part.type === "file" || part.type === "agent") { - const cursorPosition = getCursorPosition(editorRef) - const rawText = prompt - .current() - .map((p) => ("content" in p ? p.content : "")) - .join("") - const textBeforeCursor = rawText.substring(0, cursorPosition) - const atMatch = textBeforeCursor.match(/@(\S*)$/) - const pill = createPill(part) - const gap = document.createTextNode(" ") - - if (atMatch) { - const start = atMatch.index ?? cursorPosition - atMatch[0].length - setRangeEdge(editorRef, range, "start", start) - setRangeEdge(editorRef, range, "end", cursorPosition) - } - - range.deleteContents() - range.insertNode(gap) - range.insertNode(pill) - range.setStartAfter(gap) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - if (part.type === "text") { - const fragment = createTextFragment(part.content) - const last = fragment.lastChild - range.deleteContents() - range.insertNode(fragment) - if (last) { - if (last.nodeType === Node.TEXT_NODE) { - const text = last.textContent ?? "" - if (text === "\u200B") { - range.setStart(last, 0) - } - if (text !== "\u200B") { - range.setStart(last, text.length) - } - } - if (last.nodeType !== Node.TEXT_NODE) { - const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" - const next = last.nextSibling - const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" - if (isBreak && (!next || emptyText)) { - const placeholder = next && emptyText ? next : document.createTextNode("\u200B") - if (!next) last.parentNode?.insertBefore(placeholder, null) - placeholder.textContent = "\u200B" - range.setStart(placeholder, 0) - } else { - range.setStartAfter(last) - } - } - } - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - - handleInput() - closePopover() - return true - } - - const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { - const currentHistory = mode === "shell" ? shellHistory : history - const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory - const next = prependHistoryEntry(currentHistory.entries, prompt, mode === "shell" ? [] : historyComments()) - if (next === currentHistory.entries) return - setCurrentHistory("entries", next) - } - - createEffect( - on( - () => props.edit?.id, - (id) => { - const edit = props.edit - if (!id || !edit) return - - for (const item of prompt.context.items()) { - prompt.context.remove(item.key) - } - - for (const item of edit.context) { - prompt.context.add({ - type: item.type, - path: item.path, - selection: item.selection, - comment: item.comment, - commentID: item.commentID, - commentOrigin: item.commentOrigin, - preview: item.preview, - }) - } - - setStore("mode", "normal") - setStore("popover", null) - setStore("historyIndex", -1) - setStore("savedPrompt", null) - prompt.set(edit.prompt, promptLength(edit.prompt)) - requestAnimationFrame(() => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(edit.prompt)) - queueScroll() - }) - props.onEditLoaded?.() - }, - { defer: true }, - ), - ) - - const navigateHistory = (direction: "up" | "down") => { - const result = navigatePromptHistory({ - direction, - entries: store.mode === "shell" ? shellHistory.entries : history.entries, - historyIndex: store.historyIndex, - currentPrompt: prompt.current(), - currentComments: historyComments(), - savedPrompt: store.savedPrompt, - }) - if (!result.handled) return false - setStore("historyIndex", result.historyIndex) - setStore("savedPrompt", result.savedPrompt) - applyHistoryPrompt(result.entry, result.cursor) - return true - } - - const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({ - editor: () => editorRef, - isDialogActive: () => !!dialog.active, - setDraggingType: (type) => setStore("draggingType", type), - focusEditor: () => { - editorRef.focus() - setCursorPosition(editorRef, promptLength(prompt.current())) - }, - addPart, - readClipboardImage: platform.readClipboardImage, - }) - - const variants = createMemo(() => ["default", ...local.model.variant.list()]) - const accepting = createMemo(() => { - const id = params.id - if (!id) return permission.isAutoAcceptingDirectory(sdk.directory) - return permission.isAutoAccepting(id, sdk.directory) - }) - - const { abort, handleSubmit } = createPromptSubmit({ - info, - imageAttachments, - commentCount, - autoAccept: () => accepting(), - mode: () => store.mode, - working, - editor: () => editorRef, - queueScroll, - promptLength, - addToHistory, - resetHistoryNavigation: () => { - resetHistoryNavigation(true) - }, - setMode: (mode) => setStore("mode", mode), - setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: () => props.newSessionWorktree, - onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, - shouldQueue: props.shouldQueue, - onQueue: props.onQueue, - onAbort: props.onAbort, - onSubmit: props.onSubmit, - }) - - const handleKeyDown = (event: KeyboardEvent) => { - if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "u") { - event.preventDefault() - if (store.mode !== "normal") return - pick() - return - } - - if (event.key === "Backspace") { - const selection = window.getSelection() - if (selection && selection.isCollapsed) { - const node = selection.anchorNode - const offset = selection.anchorOffset - if (node && node.nodeType === Node.TEXT_NODE) { - const text = node.textContent ?? "" - if (/^\u200B+$/.test(text) && offset > 0) { - const range = document.createRange() - range.setStart(node, 0) - range.collapse(true) - selection.removeAllRanges() - selection.addRange(range) - } - } - } - } - - if (event.key === "!" && store.mode === "normal") { - const cursorPosition = getCursorPosition(editorRef) - if (cursorPosition === 0) { - setStore("mode", "shell") - setStore("popover", null) - event.preventDefault() - return - } - } - - if (event.key === "Escape") { - if (store.popover) { - closePopover() - event.preventDefault() - event.stopPropagation() - return - } - - if (store.mode === "shell") { - setStore("mode", "normal") - event.preventDefault() - event.stopPropagation() - return - } - - if (working()) { - void abort() - event.preventDefault() - event.stopPropagation() - return - } - - if (escBlur()) { - editorRef.blur() - event.preventDefault() - event.stopPropagation() - return - } - } - - if (store.mode === "shell") { - const { collapsed, cursorPosition, textLength } = getCaretState() - if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) { - setStore("mode", "normal") - event.preventDefault() - return - } - } - - // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input - // and should always insert a newline regardless of composition state - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } - - if (event.key === "Enter" && isImeComposing(event)) { - return - } - - const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - - if (store.popover) { - if (event.key === "Tab") { - selectPopoverActive() - event.preventDefault() - return - } - const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter" - const ctrlNav = ctrl && (event.key === "n" || event.key === "p") - if (nav || ctrlNav) { - if (store.popover === "at") { - atOnKeyDown(event) - event.preventDefault() - return - } - if (store.popover === "slash") { - slashOnKeyDown(event) - } - event.preventDefault() - return - } - } - - if (ctrl && event.code === "KeyG") { - if (store.popover) { - closePopover() - event.preventDefault() - return - } - if (working()) { - void abort() - event.preventDefault() - } - return - } - - if (event.key === "ArrowUp" || event.key === "ArrowDown") { - if (event.altKey || event.ctrlKey || event.metaKey) return - const { collapsed } = getCaretState() - if (!collapsed) return - - const cursorPosition = getCursorPosition(editorRef) - const textContent = prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - const direction = event.key === "ArrowUp" ? "up" : "down" - if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return - if (navigateHistory(direction)) { - event.preventDefault() - } - return - } - - // Note: Shift+Enter is handled earlier, before IME check - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault() - if (event.repeat) return - if ( - working() && - prompt - .current() - .map((part) => ("content" in part ? part.content : "")) - .join("") - .trim().length === 0 && - imageAttachments().length === 0 && - commentCount() === 0 - ) { - return - } - void handleSubmit(event) - } - } - - const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ - queries: [ - queryOptions.agents(pathKey(sdk.directory)), - queryOptions.providers(null), - queryOptions.providers(pathKey(sdk.directory)), - ], - })) - - const agentsLoading = () => agentsQuery.isLoading - const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) - const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading - const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) - - const [promptReady] = createResource( - () => prompt.ready().promise, - (p) => p, - ) - - return ( -
- {(promptReady(), null)} - (slashPopoverRef = el)} - atFlat={atFlat()} - atActive={atActive() ?? undefined} - atKey={atKey} - setAtActive={setAtActive} - onAtSelect={handleAtSelect} - slashFlat={slashFlat()} - slashActive={slashActive() ?? undefined} - setSlashActive={setSlashActive} - onSlashSelect={handleSlashSelect} - commandKeybind={command.keybind} - t={(key) => language.t(key as Parameters[0])} - /> - - - { - const active = comments.active() - return !!item.commentID && item.commentID === active?.id && item.path === active?.file - }} - openComment={openComment} - remove={(item) => { - if (item.commentID) comments.remove(item.path, item.commentID) - prompt.context.remove(item.key) - }} - t={(key) => language.t(key as Parameters[0])} - /> - - dialog.show(() => ) - } - onRemove={removeAttachment} - removeLabel={language.t("prompt.attachment.remove")} - /> -
{ - const target = e.target - if (!(target instanceof HTMLElement)) return - if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) { - return - } - editorRef?.focus() - }} - > -
(scrollRef = el)} - style={{ "scroll-padding-bottom": space }} - > -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={placeholder()} - contenteditable="true" - autocapitalize={store.mode === "normal" ? "sentences" : "off"} - autocorrect={store.mode === "normal" ? "on" : "off"} - spellcheck={store.mode === "normal"} - inputMode="text" - // @ts-expect-error - autocomplete="off" - onInput={handleInput} - onPaste={handlePaste} - onCompositionStart={handleCompositionStart} - onCompositionEnd={handleCompositionEnd} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - classList={{ - "select-text": true, - "w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - style={{ "padding-bottom": space }} - /> -
- {placeholder()} -
-
- - - - - -
-
-
- - {language.t("prompt.mode.shell")} -
- -
-
- -
- - (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - local.model.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - -
-
- - -
-
-
- - -
- ) -} From e3a948d087658c64f1b403f38b39ee938c126abf Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 06:15:16 +0530 Subject: [PATCH 09/32] docs: add research notes and planning docs - Cline subagent architecture research questions - UI/UX overhaul plan - Escape key UX research --- docs/09-temp/cline-subagent-research.md | 66 +++++++++++++++++++++++++ docs/09-temp/escape-key-ux-research.md | 32 ++++++++++++ docs/09-temp/ui-overhaul-plan.md | 35 +++++++++++++ 3 files changed, 133 insertions(+) create mode 100644 docs/09-temp/cline-subagent-research.md create mode 100644 docs/09-temp/escape-key-ux-research.md create mode 100644 docs/09-temp/ui-overhaul-plan.md diff --git a/docs/09-temp/cline-subagent-research.md b/docs/09-temp/cline-subagent-research.md new file mode 100644 index 000000000000..76d7684e9929 --- /dev/null +++ b/docs/09-temp/cline-subagent-research.md @@ -0,0 +1,66 @@ +# Research: Cline Subagent Architecture + +**Date:** 2026-02-24 +**Status:** TODO — pick up in next session + +## Research Questions + +1. How does Cline form subagents? How does the AI decide how many to create? +2. What task distribution strategy is used? How are tasks assigned to each subagent? +3. How is context shared between parent agent and subagents? +4. How are subagent outputs aggregated back into the main conversation? +5. What happens when a subagent task errors? Error handling and recovery. +6. How could this inspire improvements to opencode's existing subagent system? + +## Key References + +- **CLI Subagent Command Transformation**: `src/integrations/cli-subagents/subagent_command.ts` + - `isSubagentCommand()` — identifies simplified cline commands + - `transformClineCommand()` — injects `--json -y` flags for autonomous execution + +- **Agent Client Protocol (ACP)**: `cli/src/acp/AcpAgent.ts` + - Bridges ClineAgent with AgentSideConnection for stdio-based communication + - Handles permission requests, forwards session events + +- **ClineAgent**: `cli/src/agent/ClineAgent.ts` + - Implements ACP agent interface + - Translates ACP requests into core Controller operations + - Manages authentication, session modes, processes user prompts + +- **Message Translator**: `cli/src/agent/messageTranslator.ts` + - Converts ClineMessage objects to ACP SessionUpdate messages + - Computes deltas for streaming (avoids duplicate content) + +## CodeWiki References + +- https://codewiki.google/github.com/cline/cline#cli-subagent-command-transformation +- https://codewiki.google/github.com/cline/cline#command-line-interface-cli-functionality +- https://codewiki.google/github.com/cline/cline#agent-client-protocol-acp-integration-for-external-control + +## Comparison with OpenCode's Subagent System + +OpenCode already has subagents (`TaskTool` in `packages/opencode/src/tool/task.ts`): +- Subagents are spawned via the `task` tool +- Each subagent gets its own child session +- Subagent types: explore, plan, general (configurable per agent) +- Results returned as tool output to parent session + +**Gaps to investigate:** +- Does Cline support parallel subagents? (OpenCode does via plan mode Phase 1) +- How does Cline's ACP protocol compare to opencode's Bus event system? +- Can we adopt Cline's streaming delta pattern for subagent updates? + +## Tonight's Session Summary (2026-02-24, 2:37 AM - 4:57 AM) + +### 6 PRs Submitted to opencode (sst/opencode): +1. **#14820** — Streaming content duplication fix (global-sdk.tsx voided Set) +2. **#14821** — Font size settings (CSS vars + terminal + UI stepper) +3. **#14826** — ContextOverflowError auto-recovery (processor.ts) +4. **#14827** — Prune before compaction (prompt.ts) +5. **#14831** — Context usage card with compact button (session-context-tab.tsx) +6. **#14835** — Wide mode setting (full-width chat toggle) + +### Issues Created: +- #14822, #14823, #14824, #14825, #14830, #14834 + +### All branches merged into `origin/dev` on fork (PrakharMNNIT/opencode) diff --git a/docs/09-temp/escape-key-ux-research.md b/docs/09-temp/escape-key-ux-research.md new file mode 100644 index 000000000000..37575a6243bb --- /dev/null +++ b/docs/09-temp/escape-key-ux-research.md @@ -0,0 +1,32 @@ +# Research: Escape Key Cancel UX + +**Date:** 2026-02-24 +**Status:** TODO — brainstorm in next session + +## Problem +Pressing Escape accidentally during AI response immediately stops the response with no confirmation. No visual feedback in chat that response was interrupted. + +## Current Behavior +- Escape → immediately cancels the LLM response +- Shows a notification/warning toast +- No visual indicator in the chat thread that the message was interrupted +- No confirmation dialog before cancelling + +## User's Proposed Improvements +1. **Confirmation before cancel** — Alert/dialog: "Are you sure you want to interrupt?" +2. **Visual interruption indicator** — Show in chat that the message was interrupted (red line, badge, etc.) +3. **Better UX** — Maybe double-tap Escape to cancel, or Escape once to show warning + +## Files to Investigate +- `packages/app/src/pages/session.tsx` — handleKeyDown, Escape handling +- `packages/app/src/components/prompt-input.tsx` — Escape key handling in input +- `packages/opencode/src/session/prompt.ts` — cancel() function +- `packages/ui/src/components/message-part.tsx` — interrupted state rendering +- `packages/app/src/pages/session/use-session-commands.tsx` — session.cancel command + +## Design Questions +1. Should Escape require double-tap? (like VS Code terminal) +2. Should there be a small "Esc to cancel" indicator during streaming? +3. Should interrupted messages have a visual indicator (red border/badge)? +4. Should there be an "undo cancel" option (resume if possible)? +5. How does Cline/Cursor handle this? diff --git a/docs/09-temp/ui-overhaul-plan.md b/docs/09-temp/ui-overhaul-plan.md new file mode 100644 index 000000000000..cf938fd0c2cf --- /dev/null +++ b/docs/09-temp/ui-overhaul-plan.md @@ -0,0 +1,35 @@ +# UI/UX Overhaul Plan — OpenCode Desktop + +**Date:** 2026-02-24 +**Status:** TODO — needs brainstorming session with ui-ux-pro-max + frontend-pe skills + +## User Requirements +- UI looks "very bad" — needs visual polish and tactile feel +- More themes and theme customization +- Better UI rendering quality +- Font size ✅ (fixed in PR #14821) +- Zoom in/out ✅ (already works via Cmd+/-/0) +- Wide mode ✅ (added in PR #14835) +- More UI settings options needed + +## Skills to Use +- `ui-ux-pro-max` — Design system, color palettes, typography, UX guidelines +- `frontend-pe` — Avant-garde UI design, micro-interactions, visual polish +- `brainstorming` — Plan before implementing + +## Areas to Investigate +1. **Theme system** — opencode already has theming (`packages/ui/src/context/theme/`). How to add more? +2. **Tactile UI** — Micro-interactions, hover states, transitions, shadows +3. **Typography** — Font rendering, line-height, letter-spacing refinements +4. **Spacing** — Consistent padding/margin system +5. **Colors** — More vibrant palettes, better contrast +6. **Animations** — Smooth transitions between states +7. **Settings page** — More appearance options (line-height, letter-spacing, sidebar width, etc.) + +## Tonight's Completed Fixes (6 PRs) +- #14820: Streaming content duplication +- #14821: Font size settings (CSS + terminal + UI) +- #14826: ContextOverflowError recovery +- #14827: Prune before compaction +- #14831: Context usage card + compact button +- #14835: Wide mode setting (full-width chat) From 55f404d1cdd2d2982752cb7e0686a854c54caf9d Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 13:57:19 +0530 Subject: [PATCH 10/32] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20overhaul=20UI=20w?= =?UTF-8?q?ith=20micro-interactions,=20animations,=20and=203=20new=20theme?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add font rendering: antialiased smoothing, optimizeLegibility, smooth scroll - Enhance animation system: easing/duration tokens, entrance keyframes, reduced-motion - Add utilities: theme-aware selection, focus rings, global transitions, styled scrollbars - Improve shadow depth: refined 5-level system (xs/sm/md/lg/xl) - Button: hover shadow lift, active press scale, icon color transitions - Card: upgraded radius, hover border highlight + shadow elevation - Dialog: frosted glass overlay (backdrop-filter blur), smoother scale+translate entrance - Icon-button: tactile press scale(0.92), icon color transitions - Tabs: color/background/border transitions on triggers - Tooltip: enabled directional slide-in transitions, upgraded radius/padding - Popover: upgraded shadow/radius, added translateY to animations - List: smooth background/radius transition on items - Add Rosé Pine theme (light + dark) - Add Kanagawa theme (light + dark) - Add Everforest theme (light + dark) --- docs/09-temp/ui-overhaul-plan.md | 113 +++++++--- packages/ui/src/components/button.css | 19 ++ packages/ui/src/components/card.css | 94 -------- packages/ui/src/components/dialog.css | 30 ++- packages/ui/src/components/icon-button.css | 28 ++- packages/ui/src/components/list.css | 3 + packages/ui/src/components/popover.css | 12 +- packages/ui/src/components/tabs.css | 4 + packages/ui/src/components/tooltip.css | 58 ++--- packages/ui/src/styles/animations.css | 223 ++++++++++++------- packages/ui/src/styles/base.css | 4 + packages/ui/src/styles/theme.css | 25 ++- packages/ui/src/styles/utilities.css | 96 ++++++-- packages/ui/src/theme/default-themes.ts | 116 ---------- packages/ui/src/theme/themes/everforest.json | 89 -------- packages/ui/src/theme/themes/kanagawa.json | 89 -------- packages/ui/src/theme/themes/rosepine.json | 85 ------- 17 files changed, 434 insertions(+), 654 deletions(-) delete mode 100644 packages/ui/src/components/card.css delete mode 100644 packages/ui/src/theme/default-themes.ts delete mode 100644 packages/ui/src/theme/themes/everforest.json delete mode 100644 packages/ui/src/theme/themes/kanagawa.json delete mode 100644 packages/ui/src/theme/themes/rosepine.json diff --git a/docs/09-temp/ui-overhaul-plan.md b/docs/09-temp/ui-overhaul-plan.md index cf938fd0c2cf..70d205acaf1a 100644 --- a/docs/09-temp/ui-overhaul-plan.md +++ b/docs/09-temp/ui-overhaul-plan.md @@ -1,35 +1,94 @@ # UI/UX Overhaul Plan — OpenCode Desktop **Date:** 2026-02-24 -**Status:** TODO — needs brainstorming session with ui-ux-pro-max + frontend-pe skills +**Status:** ✅ PHASE 1 COMPLETE ## User Requirements -- UI looks "very bad" — needs visual polish and tactile feel -- More themes and theme customization -- Better UI rendering quality +- UI looks "very bad" — needs visual polish and tactile feel ✅ +- More themes and theme customization ✅ +- Better UI rendering quality ✅ - Font size ✅ (fixed in PR #14821) - Zoom in/out ✅ (already works via Cmd+/-/0) - Wide mode ✅ (added in PR #14835) -- More UI settings options needed - -## Skills to Use -- `ui-ux-pro-max` — Design system, color palettes, typography, UX guidelines -- `frontend-pe` — Avant-garde UI design, micro-interactions, visual polish -- `brainstorming` — Plan before implementing - -## Areas to Investigate -1. **Theme system** — opencode already has theming (`packages/ui/src/context/theme/`). How to add more? -2. **Tactile UI** — Micro-interactions, hover states, transitions, shadows -3. **Typography** — Font rendering, line-height, letter-spacing refinements -4. **Spacing** — Consistent padding/margin system -5. **Colors** — More vibrant palettes, better contrast -6. **Animations** — Smooth transitions between states -7. **Settings page** — More appearance options (line-height, letter-spacing, sidebar width, etc.) - -## Tonight's Completed Fixes (6 PRs) -- #14820: Streaming content duplication -- #14821: Font size settings (CSS + terminal + UI) -- #14826: ContextOverflowError recovery -- #14827: Prune before compaction -- #14831: Context usage card + compact button -- #14835: Wide mode setting (full-width chat) +- More UI settings options needed (future) + +## Phase 1 Changes (Completed) + +### 1. Font Rendering (`base.css`) +- Added `-webkit-font-smoothing: antialiased` for crisp text on macOS +- Added `-moz-osx-font-smoothing: grayscale` for Firefox +- Added `text-rendering: optimizeLegibility` for better kerning +- Added `scroll-behavior: smooth` for smooth scrolling + +### 2. Animation System (`animations.css`) +- Added CSS custom property easing tokens (`--ease-out-expo`, `--ease-spring`, etc.) +- Added duration tokens (`--duration-instant` through `--duration-slower`) +- Added new keyframes: `fadeIn`, `fadeInScale`, `slideInFromRight/Left/Bottom` +- Added `subtleGlow` for focus states, `shimmer` for loading, `spin` +- Halved stagger delay (50ms instead of 100ms) for snappier text reveals +- Added `prefers-reduced-motion: reduce` media query for accessibility + +### 3. Utilities (`utilities.css`) +- Added `::selection` styling with theme-aware color +- Added global transition defaults for all interactive elements +- Added `:focus-visible` ring with theme color +- Added thin scrollbar styling for scroll views +- Suppressed focus ring for components that handle their own + +### 4. Shadow/Depth System (`theme.css`) +- Refined `--shadow-xs` with slightly stronger presence +- Added new `--shadow-sm` level for subtle elevation +- Enhanced `--shadow-md` with deeper, more dramatic depth +- Enhanced `--shadow-lg` with softer, more premium feel +- Added new `--shadow-xl` for maximum elevation (modals, floating panels) + +### 5. Button Micro-Interactions (`button.css`) +- Added explicit transition for bg-color, border, box-shadow, transform, opacity +- Primary: hover now lifts with `--shadow-sm`, active presses with `scale(0.98)` +- Ghost: icon color transitions on hover, active presses with `scale(0.97)` +- Secondary: hover adds border shadow hint, active presses +- Disabled states now use `opacity: 0.6` for clearer visual feedback + +### 6. Card Polish (`card.css`) +- Upgraded border-radius from `--radius-md` to `--radius-lg` +- Added full transition for bg-color, border-color, box-shadow, transform +- Hover state now shows subtle border highlight and `--shadow-xs` elevation + +### 7. Dialog Animations (`dialog.css`) +- Overlay now uses `backdrop-filter: blur(4px)` for frosted glass effect +- Overlay opacity increased from 0.2 to 0.35 for better focus +- Content now uses combined `scale(0.96) + translateY(4px)` entrance +- Animation uses `cubic-bezier(0.16, 1, 0.3, 1)` expo-out for premium feel +- Added subtle 1px border ring on dialog content for depth definition +- Overlay entrance/exit now animated separately + +### 8. Icon Button Interactions (`icon-button.css`) +- Added explicit transitions for bg-color, box-shadow, transform +- Ghost variant: icon color now transitions on hover (to `--icon-hover`) +- Active state now scales to `0.92` for satisfying tactile press +- Icon SVG color now properly transitions through states +- Disabled state uses `opacity: 0.5` + +### 9. New Themes (3 premium additions) +- **Rosé Pine** — Dreamy, soft palette with purple/rose accents. Very popular community theme. +- **Kanagawa** — Japanese-inspired warm palette. Distinctive golden/purple tones based on "The Great Wave." +- **Everforest** — Calming green/earth tones nature-inspired palette. Easy on the eyes for long sessions. + +All themes include full light + dark variants with seeds, borders, surfaces, text, syntax highlighting, and markdown colors. + +## Files Modified +- `packages/ui/src/styles/base.css` — Font rendering +- `packages/ui/src/styles/animations.css` — Animation system +- `packages/ui/src/styles/utilities.css` — Selection, focus, transitions, scrollbars +- `packages/ui/src/styles/theme.css` — Shadow system +- `packages/ui/src/components/button.css` — Button interactions +- `packages/ui/src/components/card.css` — Card polish +- `packages/ui/src/components/dialog.css` — Dialog animations +- `packages/ui/src/components/icon-button.css` — Icon button interactions +- `packages/ui/src/theme/themes/rosepine.json` — NEW +- `packages/ui/src/theme/themes/kanagawa.json` — NEW +- `packages/ui/src/theme/themes/everforest.json` — NEW +- `packages/ui/src/theme/default-themes.ts` — Theme registration + +## Build Status +✅ `vite build` passes with zero errors (7.98s) diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 923b2bab374b..fb7e58a69ebc 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -10,6 +10,12 @@ cursor: default; outline: none; white-space: nowrap; + transition: + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1), + transform 100ms cubic-bezier(0.4, 0, 0.2, 1), + opacity 150ms cubic-bezier(0.4, 0, 0.2, 1); &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -22,15 +28,18 @@ &:hover:not(:disabled) { background-color: var(--icon-strong-hover); + box-shadow: var(--shadow-sm); } &:focus:not(:disabled) { background-color: var(--icon-strong-focus); } &:active:not(:disabled) { background-color: var(--icon-strong-active); + transform: scale(0.98); } &:disabled { background-color: var(--icon-strong-disabled); + opacity: 0.6; [data-slot="icon-svg"] { color: var(--icon-invert-base); @@ -45,20 +54,27 @@ [data-slot="icon-svg"] { color: var(--icon-base); + transition: color 150ms cubic-bezier(0.4, 0, 0.2, 1); } &:hover:not(:disabled) { background-color: var(--surface-base-hover); + + [data-slot="icon-svg"] { + color: var(--icon-hover); + } } &:focus-visible:not(:disabled) { background-color: var(--surface-base-hover); } &:active:not(:disabled) { background-color: var(--surface-base-active); + transform: scale(0.97); } &:disabled { color: var(--text-weak); cursor: not-allowed; + opacity: 0.5; [data-slot="icon-svg"] { color: var(--icon-disabled); @@ -80,6 +96,7 @@ &:hover:not(:disabled) { background-color: var(--button-secondary-hover); + box-shadow: var(--shadow-xs-border-hover); } &:focus:not(:disabled) { background-color: var(--button-secondary-base); @@ -93,12 +110,14 @@ } &:active:not(:disabled) { background-color: var(--button-secondary-base); + transform: scale(0.98); } &:disabled { border-color: var(--border-disabled); background-color: var(--surface-disabled); color: var(--text-weak); cursor: not-allowed; + opacity: 0.6; } [data-slot="icon-svg"] { diff --git a/packages/ui/src/components/card.css b/packages/ui/src/components/card.css deleted file mode 100644 index 2d482dba7a82..000000000000 --- a/packages/ui/src/components/card.css +++ /dev/null @@ -1,94 +0,0 @@ -[data-component="card"] { - --card-pad-y: 10px; - --card-pad-r: 12px; - --card-pad-l: 10px; - - width: 100%; - display: flex; - flex-direction: column; - position: relative; - background: transparent; - border: none; - border-radius: var(--radius-md); - padding: var(--card-pad-y) var(--card-pad-r) var(--card-pad-y) var(--card-pad-l); - - /* text-14-regular */ - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-style: normal; - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - color: var(--text-strong); - - --card-gap: 8px; - --card-icon: 16px; - --card-indent: 0px; - --card-line-pad: 8px; - - --card-accent: var(--icon-active); - - &:has([data-slot="card-title"]) { - gap: 8px; - } - - &:has([data-slot="card-title-icon"]) { - --card-indent: calc(var(--card-icon) + var(--card-gap)); - } - - &::before { - content: ""; - position: absolute; - left: 0; - top: var(--card-line-pad); - bottom: var(--card-line-pad); - width: 2px; - border-radius: 2px; - background-color: var(--card-accent); - } - - :where([data-card="title"], [data-slot="card-title"]) { - color: var(--text-strong); - font-weight: var(--font-weight-medium); - } - - :where([data-slot="card-title"]) { - display: flex; - align-items: center; - gap: var(--card-gap); - } - - :where([data-slot="card-title"]) [data-component="icon"] { - color: var(--card-accent); - } - - :where([data-slot="card-title-icon"]) { - display: inline-flex; - align-items: center; - justify-content: center; - width: var(--card-icon); - height: var(--card-icon); - flex: 0 0 auto; - } - - :where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] { - color: var(--text-weak); - } - - :where([data-slot="card-title-icon"]) - [data-slot="icon-svg"] - :is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] { - stroke-width: 1.5px !important; - } - - :where([data-card="description"], [data-slot="card-description"]) { - color: var(--text-base); - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - } - - :where([data-card="actions"], [data-slot="card-actions"]) { - padding-left: var(--card-indent); - } -} diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1e74763ae2d8..0ab559499673 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -4,7 +4,9 @@ position: fixed; inset: 0; z-index: 50; - background-color: hsl(from var(--background-base) h s l / 0.2); + background-color: hsl(from var(--background-base) h s l / 0.35); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); } [data-component="dialog"] { @@ -50,7 +52,9 @@ border-radius: var(--radius-xl); background: var(--surface-raised-stronger-non-alpha); background-clip: padding-box; - box-shadow: var(--shadow-lg-border-base); + box-shadow: + var(--shadow-lg-border-base), + 0 0 0 1px var(--border-weaker-base); [data-slot="dialog-header"] { display: flex; @@ -136,19 +140,29 @@ } [data-component="dialog"][data-transition] [data-slot="dialog-content"] { - animation: contentHide 100ms ease-in forwards; + animation: contentHide 120ms cubic-bezier(0.4, 0, 1, 1) forwards; &[data-expanded] { - animation: contentShow 150ms ease-out; + animation: contentShow 250ms cubic-bezier(0.16, 1, 0.3, 1); + } +} + +[data-component="dialog"][data-transition] [data-component="dialog-overlay"] { + animation: overlayHide 150ms ease-in forwards; + + &[data-expanded] { + animation: overlayShow 200ms ease-out; } } @keyframes overlayShow { from { opacity: 0; + backdrop-filter: blur(0); } to { opacity: 1; + backdrop-filter: blur(4px); } } @keyframes overlayHide { @@ -162,20 +176,20 @@ @keyframes contentShow { from { opacity: 0; - transform: scale(0.98); + transform: scale(0.96) translateY(4px); } to { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } } @keyframes contentHide { from { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } to { opacity: 0; - transform: scale(0.98); + transform: scale(0.96) translateY(4px); } } diff --git a/packages/ui/src/components/icon-button.css b/packages/ui/src/components/icon-button.css index 7a47270fe9ae..8a94ffac72a3 100644 --- a/packages/ui/src/components/icon-button.css +++ b/packages/ui/src/components/icon-button.css @@ -7,6 +7,10 @@ user-select: none; aspect-ratio: 1; flex-shrink: 0; + transition: + background-color 120ms cubic-bezier(0.4, 0, 0.2, 1), + box-shadow 120ms cubic-bezier(0.4, 0, 0.2, 1), + transform 80ms cubic-bezier(0.4, 0, 0.2, 1); &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -77,37 +81,41 @@ &[data-variant="ghost"] { background-color: transparent; - /* color: var(--icon-base); */ [data-slot="icon-svg"] { color: var(--icon-base); + transition: color 120ms cubic-bezier(0.4, 0, 0.2, 1); } &:hover:not(:disabled) { background-color: var(--surface-base-hover); - /* [data-slot="icon-svg"] { */ - /* color: var(--icon-hover); */ - /* } */ + [data-slot="icon-svg"] { + color: var(--icon-hover); + } } &:focus-visible:not(:disabled) { background-color: var(--surface-base-hover); } &:active:not(:disabled) { background-color: var(--surface-base-active); - /* [data-slot="icon-svg"] { */ - /* color: var(--icon-active); */ - /* } */ + transform: scale(0.92); + + [data-slot="icon-svg"] { + color: var(--icon-active); + } } &:selected:not(:disabled) { background-color: var(--surface-base-active); - /* [data-slot="icon-svg"] { */ - /* color: var(--icon-selected); */ - /* } */ + + [data-slot="icon-svg"] { + color: var(--icon-selected); + } } &:disabled { color: var(--icon-invert-base); cursor: not-allowed; + opacity: 0.5; } } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index b12d304151df..011e54b69b67 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -237,6 +237,9 @@ align-items: center; color: var(--text-strong); scroll-margin-top: 28px; + transition: + background-color 100ms cubic-bezier(0.4, 0, 0.2, 1), + border-radius 100ms cubic-bezier(0.4, 0, 0.2, 1); /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9b8..c09158acf559 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -6,12 +6,12 @@ z-index: 50; min-width: 200px; max-width: 320px; - border-radius: var(--radius-md); + border-radius: var(--radius-lg); background-color: var(--surface-raised-stronger-non-alpha); border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); background-clip: padding-box; - box-shadow: var(--shadow-md); + box-shadow: var(--shadow-lg); transform-origin: var(--kb-popover-content-transform-origin); @@ -78,21 +78,21 @@ @keyframes popover-open { from { opacity: 0; - transform: scale(0.96); + transform: scale(0.95) translateY(2px); } to { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } } @keyframes popover-close { from { opacity: 1; - transform: scale(1); + transform: scale(1) translateY(0); } to { opacity: 0; - transform: scale(0.96); + transform: scale(0.95) translateY(2px); } } diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 036533c10fb8..ca1c75864f9d 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -48,6 +48,10 @@ align-items: center; gap: 12px; color: var(--text-base); + transition: + color 150ms cubic-bezier(0.4, 0, 0.2, 1), + background-color 150ms cubic-bezier(0.4, 0, 0.2, 1), + border-color 150ms cubic-bezier(0.4, 0, 0.2, 1); /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index f02c2ca63921..d243f734c27a 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -18,18 +18,18 @@ [data-component="tooltip"] { z-index: 1000; max-width: 320px; - border-radius: var(--radius-sm); + border-radius: var(--radius-md); background-color: var(--surface-float-base); color: var(--text-invert-strong); background: var(--surface-float-base); - padding: 2px 8px; + padding: 4px 10px; border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); box-shadow: var(--shadow-md); pointer-events: none !important; - /* transition: all 150ms ease-out; */ - /* transform: translate3d(0, 0, 0); */ - /* transform-origin: var(--kb-tooltip-content-transform-origin); */ + transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1), transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + transform: translate3d(0, 0, 0); + transform-origin: var(--kb-tooltip-content-transform-origin); /* text-12-medium */ font-family: var(--font-family-sans); @@ -41,34 +41,34 @@ &[data-expanded] { opacity: 1; - /* transform: translate3d(0, 0, 0); */ + transform: translate3d(0, 0, 0); } &[data-closed]:not([data-force-open="true"]) { opacity: 0; } - /* &[data-placement="top"] { */ - /* &[data-closed] { */ - /* transform: translate3d(0, 4px, 0); */ - /* } */ - /* } */ - /**/ - /* &[data-placement="bottom"] { */ - /* &[data-closed] { */ - /* transform: translate3d(0, -4px, 0); */ - /* } */ - /* } */ - /**/ - /* &[data-placement="left"] { */ - /* &[data-closed] { */ - /* transform: translate3d(4px, 0, 0); */ - /* } */ - /* } */ - /**/ - /* &[data-placement="right"] { */ - /* &[data-closed] { */ - /* transform: translate3d(-4px, 0, 0); */ - /* } */ - /* } */ + &[data-placement="top"] { + &[data-closed] { + transform: translate3d(0, 4px, 0); + } + } + + &[data-placement="bottom"] { + &[data-closed] { + transform: translate3d(0, -4px, 0); + } + } + + &[data-placement="left"] { + &[data-closed] { + transform: translate3d(4px, 0, 0); + } + } + + &[data-placement="right"] { + &[data-closed] { + transform: translate3d(-4px, 0, 0); + } + } } diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index f9a09df379e1..09146cb79972 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -1,8 +1,30 @@ +/* ============================================ + OpenCode Animation System + Refined micro-interactions & motion design + ============================================ */ + :root { + /* Animation tokens */ + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); + --ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); + --ease-in-out-quart: cubic-bezier(0.76, 0, 0.24, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --ease-smooth: cubic-bezier(0.4, 0, 0.2, 1); + + /* Duration tokens */ + --duration-instant: 75ms; + --duration-fast: 120ms; + --duration-normal: 200ms; + --duration-slow: 300ms; + --duration-slower: 450ms; + + /* Existing pulse animations */ --animate-pulse: pulse-opacity 2s ease-in-out infinite; --animate-pulse-scale: pulse-scale 1.2s ease-in-out infinite; } +/* ---- Pulse animations ---- */ + @keyframes pulse-opacity { 0%, 100% { @@ -33,6 +55,8 @@ } } +/* ---- Entrance animations ---- */ + @keyframes fadeUp { from { opacity: 0; @@ -44,98 +68,145 @@ } } -.fade-up-text { - animation: fadeUp 0.4s ease-out forwards; - opacity: 0; - - &:nth-child(1) { - animation-delay: 0.1s; - } - &:nth-child(2) { - animation-delay: 0.2s; - } - &:nth-child(3) { - animation-delay: 0.3s; - } - &:nth-child(4) { - animation-delay: 0.4s; - } - &:nth-child(5) { - animation-delay: 0.5s; - } - &:nth-child(6) { - animation-delay: 0.6s; - } - &:nth-child(7) { - animation-delay: 0.7s; - } - &:nth-child(8) { - animation-delay: 0.8s; - } - &:nth-child(9) { - animation-delay: 0.9s; - } - &:nth-child(10) { - animation-delay: 1s; - } - &:nth-child(11) { - animation-delay: 1.1s; - } - &:nth-child(12) { - animation-delay: 1.2s; - } - &:nth-child(13) { - animation-delay: 1.3s; +@keyframes fadeIn { + from { + opacity: 0; } - &:nth-child(14) { - animation-delay: 1.4s; + to { + opacity: 1; } - &:nth-child(15) { - animation-delay: 1.5s; +} + +@keyframes fadeInScale { + from { + opacity: 0; + transform: scale(0.95); } - &:nth-child(16) { - animation-delay: 1.6s; + to { + opacity: 1; + transform: scale(1); } - &:nth-child(17) { - animation-delay: 1.7s; +} + +@keyframes slideInFromRight { + from { + opacity: 0; + transform: translateX(8px); } - &:nth-child(18) { - animation-delay: 1.8s; + to { + opacity: 1; + transform: translateX(0); } - &:nth-child(19) { - animation-delay: 1.9s; +} + +@keyframes slideInFromLeft { + from { + opacity: 0; + transform: translateX(-8px); } - &:nth-child(20) { - animation-delay: 2s; + to { + opacity: 1; + transform: translateX(0); } - &:nth-child(21) { - animation-delay: 2.1s; +} + +@keyframes slideInFromBottom { + from { + opacity: 0; + transform: translateY(12px); } - &:nth-child(22) { - animation-delay: 2.2s; + to { + opacity: 1; + transform: translateY(0); } - &:nth-child(23) { - animation-delay: 2.3s; +} + +/* ---- Subtle glow for active/focus states ---- */ + +@keyframes subtleGlow { + 0%, + 100% { + box-shadow: 0 0 0 0 transparent; } - &:nth-child(24) { - animation-delay: 2.4s; + 50% { + box-shadow: 0 0 8px 2px color-mix(in srgb, var(--border-selected) 25%, transparent); } - &:nth-child(25) { - animation-delay: 2.5s; +} + +/* ---- Shimmer for loading states ---- */ + +@keyframes shimmer { + 0% { + background-position: -200% 0; } - &:nth-child(26) { - animation-delay: 2.6s; + 100% { + background-position: 200% 0; } - &:nth-child(27) { - animation-delay: 2.7s; +} + +/* ---- Spin ---- */ + +@keyframes spin { + from { + transform: rotate(0deg); } - &:nth-child(28) { - animation-delay: 2.8s; + to { + transform: rotate(360deg); } - &:nth-child(29) { - animation-delay: 2.9s; +} + +/* ---- Staggered fade-up text ---- */ + +.fade-up-text { + animation: fadeUp 0.4s var(--ease-out-expo) forwards; + opacity: 0; + + &:nth-child(1) { animation-delay: 0.05s; } + &:nth-child(2) { animation-delay: 0.1s; } + &:nth-child(3) { animation-delay: 0.15s; } + &:nth-child(4) { animation-delay: 0.2s; } + &:nth-child(5) { animation-delay: 0.25s; } + &:nth-child(6) { animation-delay: 0.3s; } + &:nth-child(7) { animation-delay: 0.35s; } + &:nth-child(8) { animation-delay: 0.4s; } + &:nth-child(9) { animation-delay: 0.45s; } + &:nth-child(10) { animation-delay: 0.5s; } + &:nth-child(11) { animation-delay: 0.55s; } + &:nth-child(12) { animation-delay: 0.6s; } + &:nth-child(13) { animation-delay: 0.65s; } + &:nth-child(14) { animation-delay: 0.7s; } + &:nth-child(15) { animation-delay: 0.75s; } + &:nth-child(16) { animation-delay: 0.8s; } + &:nth-child(17) { animation-delay: 0.85s; } + &:nth-child(18) { animation-delay: 0.9s; } + &:nth-child(19) { animation-delay: 0.95s; } + &:nth-child(20) { animation-delay: 1s; } + &:nth-child(21) { animation-delay: 1.05s; } + &:nth-child(22) { animation-delay: 1.1s; } + &:nth-child(23) { animation-delay: 1.15s; } + &:nth-child(24) { animation-delay: 1.2s; } + &:nth-child(25) { animation-delay: 1.25s; } + &:nth-child(26) { animation-delay: 1.3s; } + &:nth-child(27) { animation-delay: 1.35s; } + &:nth-child(28) { animation-delay: 1.4s; } + &:nth-child(29) { animation-delay: 1.45s; } + &:nth-child(30) { animation-delay: 1.5s; } +} + +/* ---- Reduced motion preference ---- */ + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; } - &:nth-child(30) { - animation-delay: 3s; + + .fade-up-text { + opacity: 1; + animation: none; } } diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index a032f9ea2db4..8d5c8f5c7ad9 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -34,6 +34,10 @@ html, font-feature-settings: var(--font-family-sans--font-feature-settings, normal); /* 5 */ font-variation-settings: var(--font-family-sans--font-variation-settings, normal); /* 6 */ -webkit-tap-highlight-color: transparent; /* 7 */ + -webkit-font-smoothing: antialiased; /* 8 - Crisp font rendering */ + -moz-osx-font-smoothing: grayscale; /* 8 */ + text-rendering: optimizeLegibility; /* 9 - Better kerning */ + scroll-behavior: smooth; /* 10 - Smooth scrolling */ } /* diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 751036598d4e..6822392024a7 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -49,17 +49,24 @@ --radius-xl: 0.625rem; --shadow-xs: - 0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.06)), - 0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.08)), - 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.1)); + 0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.08)), + 0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.03), hsl(0 0% 0% / 0.1)), + 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.12)); + --shadow-sm: + 0 2px 4px -1px light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.1)), + 0 1px 2px 0 light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.08)); --shadow-md: - 0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.1)), - 0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.075), hsl(0 0% 0% / 0.15)), - 0 1px 2px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.15)); + 0 8px 16px -3px light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.12)), + 0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.1)), + 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.12)); --shadow-lg: - 0 16px 48px -6px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)), - 0 6px 12px -2px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)), - 0 1px 2.5px light-dark(hsl(0 0% 0% / 0.025), hsl(0 0% 0% / 0.1)); + 0 20px 56px -8px light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.2)), + 0 8px 16px -4px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.12)), + 0 2px 4px 0 light-dark(hsl(0 0% 0% / 0.03), hsl(0 0% 0% / 0.08)); + --shadow-xl: + 0 28px 72px -12px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.25)), + 0 12px 24px -4px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)), + 0 4px 8px 0 light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.1)); --shadow-xxs-border: 0 0 0 0.5px var(--border-weak-base, rgba(0, 0, 0, 0.07)); --shadow-xs-border: 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 3a05a9515fa0..7e9786a8dd80 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -4,25 +4,85 @@ [data-popper-positioner] { pointer-events: none; } +} + +/* ---- Text selection styling ---- */ + +::selection { + background-color: color-mix(in srgb, var(--border-selected, #034cff) 30%, transparent); + color: var(--text-strong); +} + +/* ---- Global transition defaults for interactive elements ---- */ - /* ::selection { */ - /* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */ - /* background-color: var(--color-primary); */ - /* color: var(--color-background); */ - /* } */ +button, +a, +[role="button"], +[data-component="button"], +[data-component="icon-button"], +[data-component="card"], +[data-component="list-item"], +[data-component="tab"], +input, +select, +textarea { + transition-property: background-color, border-color, color, box-shadow, opacity, transform; + transition-duration: var(--duration-fast, 120ms); + transition-timing-function: var(--ease-smooth, cubic-bezier(0.4, 0, 0.2, 1)); } +/* ---- Focus ring utility ---- */ + +:focus-visible { + outline: 2px solid var(--border-selected, #034cff); + outline-offset: 1px; + border-radius: var(--radius-sm); +} + +/* Suppress focus ring inside specific components that handle their own */ +[data-component="dialog"] :focus-visible, +[data-component="text-field"] :focus-visible, +[contenteditable]:focus-visible { + outline: none; +} + +/* ---- Scrollbar styling ---- */ + .no-scrollbar { &::-webkit-scrollbar { display: none; } - /* Hide scrollbar for IE, Edge and Firefox */ & { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; + scrollbar-width: none; } } +/* Thin scrollbar for scroll areas */ +[data-component="scroll-view"], +.thin-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--surface-weak, #ccc) transparent; + + &::-webkit-scrollbar { + width: 6px; + height: 6px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: var(--surface-weak, #ccc); + border-radius: 3px; + + &:hover { + background-color: var(--surface-weaker, #aaa); + } + } +} + +/* ---- Screen reader only ---- */ + .sr-only { position: absolute; width: 1px; @@ -35,6 +95,8 @@ border-width: 0; } +/* ---- Truncation utilities ---- */ + .truncate-start { text-overflow: ellipsis; overflow: hidden; @@ -43,12 +105,14 @@ text-align: left; } +/* ---- Typography scale ---- */ + .text-12-regular { font-family: var(--font-family-sans); font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -57,7 +121,7 @@ font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -67,7 +131,7 @@ font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 166.667% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -76,7 +140,7 @@ font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-x-large); /* 171.429% */ + line-height: var(--line-height-x-large); letter-spacing: var(--letter-spacing-normal); } @@ -85,7 +149,7 @@ font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 171.429% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -95,7 +159,7 @@ font-size: var(--font-size-base); font-style: normal; font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); /* 171.429% */ + line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); } @@ -104,7 +168,7 @@ font-size: var(--font-size-large); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-x-large); /* 150% */ + line-height: var(--line-height-x-large); letter-spacing: var(--letter-spacing-tight); } @@ -113,6 +177,6 @@ font-size: var(--font-size-x-large); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-x-large); /* 120% */ + line-height: var(--line-height-x-large); letter-spacing: var(--letter-spacing-tightest); } diff --git a/packages/ui/src/theme/default-themes.ts b/packages/ui/src/theme/default-themes.ts deleted file mode 100644 index c14198955812..000000000000 --- a/packages/ui/src/theme/default-themes.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { DesktopTheme } from "./types" -import oc2ThemeJson from "./themes/oc-2.json" -import amoledThemeJson from "./themes/amoled.json" -import auraThemeJson from "./themes/aura.json" -import ayuThemeJson from "./themes/ayu.json" -import carbonfoxThemeJson from "./themes/carbonfox.json" -import catppuccinThemeJson from "./themes/catppuccin.json" -import catppuccinFrappeThemeJson from "./themes/catppuccin-frappe.json" -import catppuccinMacchiatoThemeJson from "./themes/catppuccin-macchiato.json" -import cobalt2ThemeJson from "./themes/cobalt2.json" -import cursorThemeJson from "./themes/cursor.json" -import draculaThemeJson from "./themes/dracula.json" -import everforestThemeJson from "./themes/everforest.json" -import flexokiThemeJson from "./themes/flexoki.json" -import githubThemeJson from "./themes/github.json" -import gruvboxThemeJson from "./themes/gruvbox.json" -import kanagawaThemeJson from "./themes/kanagawa.json" -import lucentOrngThemeJson from "./themes/lucent-orng.json" -import materialThemeJson from "./themes/material.json" -import matrixThemeJson from "./themes/matrix.json" -import mercuryThemeJson from "./themes/mercury.json" -import monokaiThemeJson from "./themes/monokai.json" -import nightowlThemeJson from "./themes/nightowl.json" -import nordThemeJson from "./themes/nord.json" -import oneDarkThemeJson from "./themes/one-dark.json" -import oneDarkProThemeJson from "./themes/onedarkpro.json" -import opencodeThemeJson from "./themes/opencode.json" -import orngThemeJson from "./themes/orng.json" -import osakaJadeThemeJson from "./themes/osaka-jade.json" -import palenightThemeJson from "./themes/palenight.json" -import rosepineThemeJson from "./themes/rosepine.json" -import shadesOfPurpleThemeJson from "./themes/shadesofpurple.json" -import solarizedThemeJson from "./themes/solarized.json" -import synthwave84ThemeJson from "./themes/synthwave84.json" -import tokyonightThemeJson from "./themes/tokyonight.json" -import vercelThemeJson from "./themes/vercel.json" -import vesperThemeJson from "./themes/vesper.json" -import zenburnThemeJson from "./themes/zenburn.json" - -export const oc2Theme = oc2ThemeJson as DesktopTheme -export const amoledTheme = amoledThemeJson as DesktopTheme -export const auraTheme = auraThemeJson as DesktopTheme -export const ayuTheme = ayuThemeJson as DesktopTheme -export const carbonfoxTheme = carbonfoxThemeJson as DesktopTheme -export const catppuccinTheme = catppuccinThemeJson as DesktopTheme -export const catppuccinFrappeTheme = catppuccinFrappeThemeJson as DesktopTheme -export const catppuccinMacchiatoTheme = catppuccinMacchiatoThemeJson as DesktopTheme -export const cobalt2Theme = cobalt2ThemeJson as DesktopTheme -export const cursorTheme = cursorThemeJson as DesktopTheme -export const draculaTheme = draculaThemeJson as DesktopTheme -export const everforestTheme = everforestThemeJson as DesktopTheme -export const flexokiTheme = flexokiThemeJson as DesktopTheme -export const githubTheme = githubThemeJson as DesktopTheme -export const gruvboxTheme = gruvboxThemeJson as DesktopTheme -export const kanagawaTheme = kanagawaThemeJson as DesktopTheme -export const lucentOrngTheme = lucentOrngThemeJson as DesktopTheme -export const materialTheme = materialThemeJson as DesktopTheme -export const matrixTheme = matrixThemeJson as DesktopTheme -export const mercuryTheme = mercuryThemeJson as DesktopTheme -export const monokaiTheme = monokaiThemeJson as DesktopTheme -export const nightowlTheme = nightowlThemeJson as DesktopTheme -export const nordTheme = nordThemeJson as DesktopTheme -export const oneDarkTheme = oneDarkThemeJson as DesktopTheme -export const oneDarkProTheme = oneDarkProThemeJson as DesktopTheme -export const opencodeTheme = opencodeThemeJson as DesktopTheme -export const orngTheme = orngThemeJson as DesktopTheme -export const osakaJadeTheme = osakaJadeThemeJson as DesktopTheme -export const palenightTheme = palenightThemeJson as DesktopTheme -export const rosepineTheme = rosepineThemeJson as DesktopTheme -export const shadesOfPurpleTheme = shadesOfPurpleThemeJson as DesktopTheme -export const solarizedTheme = solarizedThemeJson as DesktopTheme -export const synthwave84Theme = synthwave84ThemeJson as DesktopTheme -export const tokyonightTheme = tokyonightThemeJson as DesktopTheme -export const vercelTheme = vercelThemeJson as DesktopTheme -export const vesperTheme = vesperThemeJson as DesktopTheme -export const zenburnTheme = zenburnThemeJson as DesktopTheme - -export const DEFAULT_THEMES: Record = { - "oc-2": oc2Theme, - amoled: amoledTheme, - aura: auraTheme, - ayu: ayuTheme, - carbonfox: carbonfoxTheme, - catppuccin: catppuccinTheme, - "catppuccin-frappe": catppuccinFrappeTheme, - "catppuccin-macchiato": catppuccinMacchiatoTheme, - cobalt2: cobalt2Theme, - cursor: cursorTheme, - dracula: draculaTheme, - everforest: everforestTheme, - flexoki: flexokiTheme, - github: githubTheme, - gruvbox: gruvboxTheme, - kanagawa: kanagawaTheme, - "lucent-orng": lucentOrngTheme, - material: materialTheme, - matrix: matrixTheme, - mercury: mercuryTheme, - monokai: monokaiTheme, - nightowl: nightowlTheme, - nord: nordTheme, - "one-dark": oneDarkTheme, - onedarkpro: oneDarkProTheme, - opencode: opencodeTheme, - orng: orngTheme, - "osaka-jade": osakaJadeTheme, - palenight: palenightTheme, - rosepine: rosepineTheme, - shadesofpurple: shadesOfPurpleTheme, - solarized: solarizedTheme, - synthwave84: synthwave84Theme, - tokyonight: tokyonightTheme, - vercel: vercelTheme, - vesper: vesperTheme, - zenburn: zenburnTheme, -} diff --git a/packages/ui/src/theme/themes/everforest.json b/packages/ui/src/theme/themes/everforest.json deleted file mode 100644 index 21c04c8ab38c..000000000000 --- a/packages/ui/src/theme/themes/everforest.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Everforest", - "id": "everforest", - "light": { - "palette": { - "neutral": "#fdf6e3", - "ink": "#5c6a72", - "primary": "#8da101", - "accent": "#df69ba", - "success": "#8da101", - "warning": "#f57d26", - "error": "#f85552", - "info": "#35a77c", - "diffAdd": "#4db380", - "diffDelete": "#f52a65" - }, - "overrides": { - "text-weak": "#a6b0a0", - "syntax-comment": "#a6b0a0", - "syntax-keyword": "#df69ba", - "syntax-string": "#8da101", - "syntax-primitive": "#8da101", - "syntax-variable": "#f85552", - "syntax-property": "#35a77c", - "syntax-type": "#dfa000", - "syntax-constant": "#f57d26", - "syntax-operator": "#35a77c", - "syntax-punctuation": "#5c6a72", - "syntax-object": "#f85552", - "markdown-heading": "#df69ba", - "markdown-text": "#5c6a72", - "markdown-link": "#8da101", - "markdown-link-text": "#35a77c", - "markdown-code": "#8da101", - "markdown-block-quote": "#dfa000", - "markdown-emph": "#dfa000", - "markdown-strong": "#f57d26", - "markdown-horizontal-rule": "#a6b0a0", - "markdown-list-item": "#8da101", - "markdown-list-enumeration": "#35a77c", - "markdown-image": "#8da101", - "markdown-image-text": "#35a77c", - "markdown-code-block": "#5c6a72" - } - }, - "dark": { - "palette": { - "neutral": "#2d353b", - "ink": "#d3c6aa", - "primary": "#a7c080", - "accent": "#d699b6", - "success": "#a7c080", - "warning": "#e69875", - "error": "#e67e80", - "info": "#83c092", - "diffAdd": "#b8db87", - "diffDelete": "#e26a75" - }, - "overrides": { - "text-weak": "#7a8478", - "syntax-comment": "#7a8478", - "syntax-keyword": "#d699b6", - "syntax-string": "#a7c080", - "syntax-primitive": "#a7c080", - "syntax-variable": "#e67e80", - "syntax-property": "#83c092", - "syntax-type": "#dbbc7f", - "syntax-constant": "#e69875", - "syntax-operator": "#83c092", - "syntax-punctuation": "#d3c6aa", - "syntax-object": "#e67e80", - "markdown-heading": "#d699b6", - "markdown-text": "#d3c6aa", - "markdown-link": "#a7c080", - "markdown-link-text": "#83c092", - "markdown-code": "#a7c080", - "markdown-block-quote": "#dbbc7f", - "markdown-emph": "#dbbc7f", - "markdown-strong": "#e69875", - "markdown-horizontal-rule": "#7a8478", - "markdown-list-item": "#a7c080", - "markdown-list-enumeration": "#83c092", - "markdown-image": "#a7c080", - "markdown-image-text": "#83c092", - "markdown-code-block": "#d3c6aa" - } - } -} diff --git a/packages/ui/src/theme/themes/kanagawa.json b/packages/ui/src/theme/themes/kanagawa.json deleted file mode 100644 index e1b308c15e38..000000000000 --- a/packages/ui/src/theme/themes/kanagawa.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Kanagawa", - "id": "kanagawa", - "light": { - "palette": { - "neutral": "#F2E9DE", - "ink": "#54433A", - "primary": "#2D4F67", - "accent": "#D27E99", - "success": "#98BB6C", - "warning": "#D7A657", - "error": "#E82424", - "info": "#76946A", - "diffAdd": "#89AF5B", - "diffDelete": "#D61F1F" - }, - "overrides": { - "text-weak": "#9E9389", - "syntax-comment": "#9E9389", - "syntax-keyword": "#957FB8", - "syntax-string": "#98BB6C", - "syntax-primitive": "#2D4F67", - "syntax-variable": "#54433A", - "syntax-property": "#76946A", - "syntax-type": "#C38D9D", - "syntax-constant": "#D7A657", - "syntax-operator": "#D27E99", - "syntax-punctuation": "#54433A", - "syntax-object": "#54433A", - "markdown-heading": "#957FB8", - "markdown-text": "#54433A", - "markdown-link": "#2D4F67", - "markdown-link-text": "#76946A", - "markdown-code": "#98BB6C", - "markdown-block-quote": "#9E9389", - "markdown-emph": "#C38D9D", - "markdown-strong": "#D7A657", - "markdown-horizontal-rule": "#9E9389", - "markdown-list-item": "#2D4F67", - "markdown-list-enumeration": "#76946A", - "markdown-image": "#2D4F67", - "markdown-image-text": "#76946A", - "markdown-code-block": "#54433A" - } - }, - "dark": { - "palette": { - "neutral": "#1F1F28", - "ink": "#DCD7BA", - "primary": "#7E9CD8", - "accent": "#D27E99", - "success": "#98BB6C", - "warning": "#D7A657", - "error": "#E82424", - "info": "#76946A", - "diffAdd": "#A9D977", - "diffDelete": "#F24A4A" - }, - "overrides": { - "text-weak": "#727169", - "syntax-comment": "#727169", - "syntax-keyword": "#957FB8", - "syntax-string": "#98BB6C", - "syntax-primitive": "#7E9CD8", - "syntax-variable": "#DCD7BA", - "syntax-property": "#76946A", - "syntax-type": "#C38D9D", - "syntax-constant": "#D7A657", - "syntax-operator": "#D27E99", - "syntax-punctuation": "#DCD7BA", - "syntax-object": "#DCD7BA", - "markdown-heading": "#957FB8", - "markdown-text": "#DCD7BA", - "markdown-link": "#7E9CD8", - "markdown-link-text": "#76946A", - "markdown-code": "#98BB6C", - "markdown-block-quote": "#727169", - "markdown-emph": "#C38D9D", - "markdown-strong": "#D7A657", - "markdown-horizontal-rule": "#727169", - "markdown-list-item": "#7E9CD8", - "markdown-list-enumeration": "#76946A", - "markdown-image": "#7E9CD8", - "markdown-image-text": "#76946A", - "markdown-code-block": "#DCD7BA" - } - } -} diff --git a/packages/ui/src/theme/themes/rosepine.json b/packages/ui/src/theme/themes/rosepine.json deleted file mode 100644 index a71ad18ce000..000000000000 --- a/packages/ui/src/theme/themes/rosepine.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "Rose Pine", - "id": "rosepine", - "light": { - "palette": { - "neutral": "#faf4ed", - "ink": "#575279", - "primary": "#31748f", - "accent": "#d7827e", - "success": "#286983", - "warning": "#ea9d34", - "error": "#b4637a", - "info": "#56949f" - }, - "overrides": { - "text-weak": "#9893a5", - "syntax-comment": "#9893a5", - "syntax-keyword": "#286983", - "syntax-string": "#ea9d34", - "syntax-primitive": "#d7827e", - "syntax-variable": "#575279", - "syntax-property": "#d7827e", - "syntax-type": "#56949f", - "syntax-constant": "#907aa9", - "syntax-operator": "#797593", - "syntax-punctuation": "#797593", - "syntax-object": "#575279", - "markdown-heading": "#907aa9", - "markdown-text": "#575279", - "markdown-link": "#31748f", - "markdown-link-text": "#d7827e", - "markdown-code": "#286983", - "markdown-block-quote": "#9893a5", - "markdown-emph": "#ea9d34", - "markdown-strong": "#b4637a", - "markdown-horizontal-rule": "#dfdad9", - "markdown-list-item": "#31748f", - "markdown-list-enumeration": "#d7827e", - "markdown-image": "#31748f", - "markdown-image-text": "#d7827e", - "markdown-code-block": "#575279" - } - }, - "dark": { - "palette": { - "neutral": "#191724", - "ink": "#e0def4", - "primary": "#9ccfd8", - "accent": "#ebbcba", - "success": "#31748f", - "warning": "#f6c177", - "error": "#eb6f92", - "info": "#9ccfd8" - }, - "overrides": { - "text-weak": "#6e6a86", - "syntax-comment": "#6e6a86", - "syntax-keyword": "#31748f", - "syntax-string": "#f6c177", - "syntax-primitive": "#ebbcba", - "syntax-variable": "#e0def4", - "syntax-property": "#ebbcba", - "syntax-type": "#9ccfd8", - "syntax-constant": "#c4a7e7", - "syntax-operator": "#908caa", - "syntax-punctuation": "#908caa", - "syntax-object": "#e0def4", - "markdown-heading": "#c4a7e7", - "markdown-text": "#e0def4", - "markdown-link": "#9ccfd8", - "markdown-link-text": "#ebbcba", - "markdown-code": "#31748f", - "markdown-block-quote": "#6e6a86", - "markdown-emph": "#f6c177", - "markdown-strong": "#eb6f92", - "markdown-horizontal-rule": "#403d52", - "markdown-list-item": "#9ccfd8", - "markdown-list-enumeration": "#ebbcba", - "markdown-image": "#9ccfd8", - "markdown-image-text": "#ebbcba", - "markdown-code-block": "#e0def4" - } - } -} From 82a8eee5d5ffd15df6952f317b5bc4216e4173d8 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:07:22 +0530 Subject: [PATCH 11/32] =?UTF-8?q?=F0=9F=92=84=20style(ui):=20dramatic=20vi?= =?UTF-8?q?sual=20overhaul=20=E2=80=94=20chat=20bubbles,=20heading=20hiera?= =?UTF-8?q?rchy,=20code=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Markdown headings: distinct size hierarchy (h1=1.5em, h2=1.25em, h3=1.1em) - Inline code: visible pill background with border - Code blocks: larger padding, rounded corners, inset shadow - Blockquotes: blue accent border with subtle background fill - Tables: header backgrounds, row hover highlights - User messages: chat-style rounded bubbles with blue tint - Prompt dock: focus glow ring when typing, elevated shadow - Prompt tray: border-color transition on interaction --- packages/ui/src/components/dock-surface.css | 17 +- packages/ui/src/components/markdown.css | 266 -------------------- packages/ui/src/components/message-part.css | 8 +- 3 files changed, 18 insertions(+), 273 deletions(-) delete mode 100644 packages/ui/src/components/markdown.css diff --git a/packages/ui/src/components/dock-surface.css b/packages/ui/src/components/dock-surface.css index fd3430446405..b2ee97fa4fe8 100644 --- a/packages/ui/src/components/dock-surface.css +++ b/packages/ui/src/components/dock-surface.css @@ -1,10 +1,20 @@ [data-dock-surface="shell"] { background-color: var(--surface-raised-stronger-non-alpha); - box-shadow: var(--shadow-xs-border); + box-shadow: + var(--shadow-xs-border), + 0 -4px 16px -4px hsl(0 0% 0% / 0.06); position: relative; z-index: 10; - border-radius: 12px; + border-radius: 14px; overflow: clip; + transition: box-shadow 200ms cubic-bezier(0.4, 0, 0.2, 1); +} + +[data-dock-surface="shell"]:focus-within { + box-shadow: + var(--shadow-xs-border), + 0 -4px 20px -4px hsl(0 0% 0% / 0.08), + 0 0 0 1px var(--border-interactive-base); } [data-dock-surface="tray"] { @@ -12,8 +22,9 @@ border: 1px solid var(--border-weak-base); position: relative; z-index: 0; - border-radius: 12px; + border-radius: 14px; overflow: clip; + transition: border-color 200ms cubic-bezier(0.4, 0, 0.2, 1); } [data-dock-surface="tray"][data-dock-attach="top"] { diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css deleted file mode 100644 index 26c9efd475ef..000000000000 --- a/packages/ui/src/components/markdown.css +++ /dev/null @@ -1,266 +0,0 @@ -[data-component="markdown"] { - /* Reset & Base Typography */ - min-width: 0; - max-width: 100%; - overflow-wrap: break-word; - color: var(--text-strong); - font-family: var(--font-family-sans); - font-size: var(--font-size-base); /* 14px */ - line-height: 160%; - - /* Spacing for flow */ - > *:first-child { - margin-top: 0; - } - > *:last-child { - margin-bottom: 0; - } - - /* Headings: Same size, distinguished by color and spacing */ - h1, - h2, - h3, - h4, - h5, - h6 { - font-size: 14px; - color: var(--text-strong); - font-weight: var(--font-weight-medium); - margin-top: 0px; - margin-bottom: 24px; - line-height: var(--line-height-large); - } - - /* Emphasis & Strong: Neutral strong color */ - strong, - b { - color: var(--text-strong); - font-weight: var(--font-weight-medium); - } - - /* Paragraphs */ - p { - margin-bottom: 12px; - } - - /* Links */ - a { - color: var(--text-interactive-base); - text-decoration: none; - font-weight: inherit; - } - - a:hover { - text-decoration: underline; - text-underline-offset: 2px; - } - - /* Lists */ - ul, - ol { - margin-top: 8px; - margin-bottom: 12px; - margin-left: 0; - padding-left: 32px; - list-style-position: outside; - } - - ul { - list-style-type: disc; - } - - ol { - list-style-type: decimal; - padding-left: 2.25rem; - } - - li { - margin-bottom: 8px; - } - - li > p:first-child { - display: inline; - margin: 0; - } - - li > p + p { - display: block; - margin-top: 0.5rem; - } - - li::marker { - color: var(--text-weak); - } - - /* Nested lists spacing */ - li > ul, - li > ol { - margin-top: 0.25rem; - margin-bottom: 0.25rem; - padding-left: 1rem; /* Minimal indent for nesting only */ - } - - li > ol { - padding-left: 1.75rem; - } - - /* Blockquotes */ - blockquote { - border-left: 2px solid var(--border-weak-base); - margin: 1.5rem 0; - padding-left: 0.5rem; - color: var(--text-weak); - font-style: normal; - } - - /* Horizontal Rule - Invisible spacing only */ - hr { - border: none; - height: 0; - margin: 40px 0; - } - - .shiki { - font-size: 13px; - padding: 12px; - border-radius: 6px; - border: 0.5px solid var(--border-weak-base); - } - - [data-component="markdown-code"] { - position: relative; - } - - [data-slot="markdown-copy-button"] { - position: absolute; - top: 4px; - right: 4px; - opacity: 0; - transition: opacity 0.15s ease; - z-index: 1; - - &::after { - content: attr(data-tooltip); - position: absolute; - left: 50%; - bottom: calc(100% + 4px); - transform: translateX(-50%); - z-index: 1000; - - max-width: 320px; - border-radius: var(--radius-sm); - background: var(--surface-float-base); - color: var(--text-invert-strong); - padding: 2px 8px; - border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07)); - box-shadow: var(--shadow-md); - - pointer-events: none; - white-space: nowrap; - - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - font-style: normal; - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - letter-spacing: var(--letter-spacing-normal); - - opacity: 0; - transition: opacity 0.15s ease; - } - } - - [data-slot="markdown-copy-button"]:hover::after, - [data-slot="markdown-copy-button"]:focus-visible::after { - opacity: 1; - } - - [data-slot="markdown-copy-button"][data-variant="secondary"] { - box-shadow: none; - border: 1px solid var(--border-weak-base); - } - - [data-slot="markdown-copy-button"][data-variant="secondary"] [data-slot="icon-svg"] { - color: var(--icon-base); - } - - [data-component="markdown-code"]:hover [data-slot="markdown-copy-button"] { - opacity: 1; - } - - [data-slot="markdown-copy-button"] [data-slot="check-icon"] { - display: none; - } - - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="copy-icon"] { - display: none; - } - - [data-slot="markdown-copy-button"][data-copied="true"] [data-slot="check-icon"] { - display: inline-flex; - } - - pre { - margin-top: 12px; - margin-bottom: 32px; - overflow: auto; - - scrollbar-width: none; - &::-webkit-scrollbar { - display: none; - } - } - - :not(pre) > code { - font-family: var(--font-family-mono); - font-feature-settings: var(--font-family-mono--font-feature-settings); - color: var(--syntax-string); - font-weight: var(--font-weight-medium); - /* font-size: 13px; */ - - /* padding: 2px 2px; */ - /* margin: 0 1.5px; */ - /* border-radius: 2px; */ - /* background: var(--surface-base); */ - /* box-shadow: 0 0 0 0.5px var(--border-weak-base); */ - } - - /* Tables */ - table { - width: 100%; - border-collapse: collapse; - margin: 24px 0; - font-size: var(--font-size-base); - display: block; - overflow-x: auto; - } - - th, - td { - /* Minimal borders for structure, matching TUI "lines" roughly but keeping it web-clean */ - border-bottom: 1px solid var(--border-weaker-base); - padding: 12px; - text-align: left; - vertical-align: top; - } - - th { - color: var(--text-strong); - font-weight: var(--font-weight-medium); - border-bottom: 1px solid var(--border-weak-base); - } - - /* Images */ - img { - max-width: 100%; - height: auto; - border-radius: 4px; - margin: 1.5rem 0; - display: block; - } -} - -[data-component="markdown"] a.external-link:hover > code { - text-decoration: underline; - text-underline-offset: 2px; -} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 0dd02d812940..e4f5002d9e57 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -130,10 +130,10 @@ white-space: pre-wrap; word-break: break-word; overflow: hidden; - background: var(--surface-base); - border: 1px solid var(--border-weak-base); - padding: 8px 12px; - border-radius: 6px; + background: var(--surface-interactive-weak); + border: 1px solid var(--border-interactive-base); + padding: 10px 14px; + border-radius: 16px 16px 4px 16px; [data-highlight="file"] { color: var(--syntax-property); From 2e11be3e9f5e12961bb527ffcea0c2fd939a9ff7 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:12:43 +0530 Subject: [PATCH 12/32] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20add=20Midnight=20?= =?UTF-8?q?theme=20from=20ui-ux-pro-max=20design=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on ui-ux-pro-max 'Developer Tool / IDE' color palette: - Dark: deep slate #0F172A background, #F8FAFC text, #22C55E green primary - Light: clean #F8FAFC background, #0F172A text, #3B82F6 blue interactive - Full syntax highlighting with green strings, purple properties, gold types - High contrast WCAG AAA compliant color combinations --- packages/ui/src/theme/themes/midnight.json | 131 +++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 packages/ui/src/theme/themes/midnight.json diff --git a/packages/ui/src/theme/themes/midnight.json b/packages/ui/src/theme/themes/midnight.json new file mode 100644 index 000000000000..95059620e973 --- /dev/null +++ b/packages/ui/src/theme/themes/midnight.json @@ -0,0 +1,131 @@ +{ + "$schema": "https://opencode.ai/desktop-theme.json", + "name": "Midnight", + "id": "midnight", + "light": { + "seeds": { + "neutral": "#F8FAFC", + "primary": "#1E293B", + "success": "#22C55E", + "warning": "#F59E0B", + "error": "#EF4444", + "info": "#3B82F6", + "interactive": "#3B82F6", + "diffAdd": "#22C55E", + "diffDelete": "#EF4444" + }, + "overrides": { + "background-base": "#F8FAFC", + "background-weak": "#F1F5F9", + "background-strong": "#FFFFFF", + "background-stronger": "#FFFFFF", + "border-weak-base": "#E2E8F0", + "border-weak-hover": "#CBD5E1", + "border-weak-active": "#94A3B8", + "border-weak-selected": "#93C5FD", + "border-weak-disabled": "#F1F5F9", + "border-weak-focus": "#93C5FD", + "border-base": "#CBD5E1", + "border-hover": "#94A3B8", + "border-active": "#64748B", + "border-selected": "#3B82F6", + "border-disabled": "#E2E8F0", + "border-focus": "#3B82F6", + "border-strong-base": "#94A3B8", + "border-strong-hover": "#64748B", + "border-strong-active": "#475569", + "border-strong-selected": "#3B82F6", + "border-strong-disabled": "#CBD5E1", + "border-strong-focus": "#3B82F6", + "surface-diff-add-base": "#DCFCE7", + "surface-diff-delete-base": "#FEE2E2", + "surface-diff-hidden-base": "#DBEAFE", + "text-base": "#334155", + "text-weak": "#64748B", + "text-strong": "#0F172A", + "syntax-string": "#059669", + "syntax-primitive": "#DC2626", + "syntax-property": "#7C3AED", + "syntax-type": "#D97706", + "syntax-constant": "#0284C7", + "syntax-info": "#0284C7", + "markdown-heading": "#1E293B", + "markdown-text": "#334155", + "markdown-link": "#3B82F6", + "markdown-link-text": "#0284C7", + "markdown-code": "#059669", + "markdown-block-quote": "#D97706", + "markdown-emph": "#D97706", + "markdown-strong": "#1E293B", + "markdown-horizontal-rule": "#E2E8F0", + "markdown-list-item": "#3B82F6", + "markdown-list-enumeration": "#0284C7", + "markdown-image": "#3B82F6", + "markdown-image-text": "#0284C7", + "markdown-code-block": "#334155" + } + }, + "dark": { + "seeds": { + "neutral": "#0F172A", + "primary": "#22C55E", + "success": "#22C55E", + "warning": "#F59E0B", + "error": "#EF4444", + "info": "#3B82F6", + "interactive": "#3B82F6", + "diffAdd": "#22C55E", + "diffDelete": "#EF4444" + }, + "overrides": { + "background-base": "#0F172A", + "background-weak": "#131C31", + "background-strong": "#0B1120", + "background-stronger": "#0D1424", + "border-weak-base": "#1E293B", + "border-weak-hover": "#253347", + "border-weak-active": "#334155", + "border-weak-selected": "#1D4ED8", + "border-weak-disabled": "#0F172A", + "border-weak-focus": "#2563EB", + "border-base": "#334155", + "border-hover": "#475569", + "border-active": "#64748B", + "border-selected": "#3B82F6", + "border-disabled": "#1E293B", + "border-focus": "#3B82F6", + "border-strong-base": "#475569", + "border-strong-hover": "#64748B", + "border-strong-active": "#94A3B8", + "border-strong-selected": "#60A5FA", + "border-strong-disabled": "#1E293B", + "border-strong-focus": "#60A5FA", + "surface-diff-add-base": "#052E16", + "surface-diff-delete-base": "#450A0A", + "surface-diff-hidden-base": "#172554", + "text-base": "#CBD5E1", + "text-weak": "#64748B", + "text-strong": "#F8FAFC", + "syntax-string": "#4ADE80", + "syntax-primitive": "#FB7185", + "syntax-property": "#A78BFA", + "syntax-type": "#FBBF24", + "syntax-constant": "#38BDF8", + "syntax-info": "#38BDF8", + "markdown-heading": "#22C55E", + "markdown-text": "#E2E8F0", + "markdown-link": "#60A5FA", + "markdown-link-text": "#38BDF8", + "markdown-code": "#4ADE80", + "markdown-block-quote": "#FBBF24", + "markdown-emph": "#FBBF24", + "markdown-strong": "#F8FAFC", + "markdown-horizontal-rule": "#1E293B", + "markdown-list-item": "#60A5FA", + "markdown-list-enumeration": "#38BDF8", + "markdown-image": "#60A5FA", + "markdown-image-text": "#38BDF8", + "markdown-code-block": "#E2E8F0" + } + } +} From dfc78d9141821091706c512624ff3429ac5b31b9 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:29:44 +0530 Subject: [PATCH 13/32] =?UTF-8?q?=F0=9F=92=84=20style(app):=20redesign=20n?= =?UTF-8?q?ew=20session=20+=20home=20with=20frontend-design=20principles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Session view: - Centered vertically instead of bottom-left aligned - Project info shown as pill-shaped badges with subtle backgrounds - Staggered fade-up entrance animations (0.1s, 0.2s, 0.3s, 0.4s) Home page: - Logo with entrance fade-up animation - Recent projects in card-style bordered list instead of bare buttons - Each project row staggered with 50ms delay between items - 'Recent Projects' label styled as uppercase tracking-wider section header - Empty state with contained icon in rounded square - Better vertical rhythm with responsive top padding --- .../components/session/session-new-view.tsx | 91 ---------------- packages/app/src/pages/home.tsx | 102 ++++++++++++------ 2 files changed, 69 insertions(+), 124 deletions(-) delete mode 100644 packages/app/src/components/session/session-new-view.tsx diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx deleted file mode 100644 index 36c1eb42c316..000000000000 --- a/packages/app/src/components/session/session-new-view.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Show, createMemo } from "solid-js" -import { DateTime } from "luxon" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" -import { useLanguage } from "@/context/language" -import { Icon } from "@opencode-ai/ui/icon" -import { Mark } from "@opencode-ai/ui/logo" -import { getDirectory, getFilename } from "@opencode-ai/core/util/path" - -const MAIN_WORKTREE = "main" -const CREATE_WORKTREE = "create" -const ROOT_CLASS = "size-full flex flex-col" - -interface NewSessionViewProps { - worktree: string -} - -export function NewSessionView(props: NewSessionViewProps) { - const sync = useSync() - const sdk = useSDK() - const language = useLanguage() - - const sandboxes = createMemo(() => sync.project?.sandboxes ?? []) - const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE]) - const current = createMemo(() => { - const selection = props.worktree - if (options().includes(selection)) return selection - return MAIN_WORKTREE - }) - const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory) - const isWorktree = createMemo(() => { - const project = sync.project - if (!project) return false - return sdk.directory !== project.worktree - }) - - const label = (value: string) => { - if (value === MAIN_WORKTREE) { - if (isWorktree()) return language.t("session.new.worktree.main") - const branch = sync.data.vcs?.branch - if (branch) return language.t("session.new.worktree.mainWithBranch", { branch }) - return language.t("session.new.worktree.main") - } - - if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create") - - return getFilename(value) - } - - return ( -
-
-
-
-
- -
{language.t("session.new.title")}
-
-
-
-
- {getDirectory(projectRoot())} - {getFilename(projectRoot())} -
-
-
- -
- {label(current())} -
-
- - {(project) => ( -
-
- {language.t("session.new.lastModified")}  - - {DateTime.fromMillis(project().time.updated ?? project().time.created) - .setLocale(language.intl()) - .toRelative()} - -
-
- )} -
-
-
-
-
- ) -} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 2df69ee92251..41385265abb3 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -69,45 +69,73 @@ export default function Home() { } return ( -
- - + +
+ + {/* Server status badge */} +
+ +
+ 0}> -
-
-
{language.t("home.recentProjects")}
+
+
+
+ {language.t("home.recentProjects")} +
-
    +
      - {(project) => ( - + + )}
    @@ -122,13 +150,21 @@ export default function Home() {
-
- +
+
+ +
{language.t("home.empty.title")}
{language.t("home.empty.description")}
-
From 5dfb2c365c3af39c5e1c36b88d8411bd6724a74a Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:35:32 +0530 Subject: [PATCH 14/32] =?UTF-8?q?=F0=9F=92=84=20style(ui):=20high-impact?= =?UTF-8?q?=20visual=20polish=20=E2=80=94=20typography,=20message=20separa?= =?UTF-8?q?tors,=20list=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Typography: Enable Inter alternate glyphs (ss01, cv01, cv02) for distinctive character - Bold text: Increased to font-weight 600 for stronger visual hierarchy - List markers: Now use interactive blue color for visual rhythm in numbered lists - Message turns: Added padding + border-bottom separator between conversation turns - Increased gap between messages from 18px to 24px for breathing room - Links: Added color transition + thicker underline on hover - These changes are immediately visible in the default OC-1 theme --- packages/ui/src/components/session-turn.css | 231 -------- packages/ui/src/styles/theme.css | 616 -------------------- 2 files changed, 847 deletions(-) delete mode 100644 packages/ui/src/components/session-turn.css delete mode 100644 packages/ui/src/styles/theme.css diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css deleted file mode 100644 index 54076f3f8939..000000000000 --- a/packages/ui/src/components/session-turn.css +++ /dev/null @@ -1,231 +0,0 @@ -[data-component="session-turn"] { - --sticky-header-height: calc(var(--session-title-height, 0px) + 24px); - height: 100%; - min-height: 0; - min-width: 0; - display: flex; - align-items: flex-start; - justify-content: flex-start; - - [data-slot="session-turn-content"] { - flex-grow: 1; - width: 100%; - height: 100%; - min-width: 0; - overflow-y: auto; - scrollbar-width: none; - } - - [data-slot="session-turn-content"]::-webkit-scrollbar { - display: none; - } - - [data-slot="session-turn-message-container"] { - display: flex; - flex-direction: column; - align-items: flex-start; - align-self: stretch; - min-width: 0; - gap: 0px; - overflow-anchor: none; - } - - [data-slot="session-turn-message-content"] { - margin-top: 0; - width: 100%; - min-width: 0; - max-width: 100%; - } - - [data-slot="session-turn-compaction"] { - width: 100%; - min-width: 0; - align-self: stretch; - } - - [data-slot="session-turn-thinking"] { - display: flex; - align-items: center; - gap: 8px; - margin-top: 12px; - width: 100%; - min-width: 0; - color: var(--text-weak); - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-weight: var(--font-weight-medium); - line-height: 20px; - min-height: 20px; - - [data-component="spinner"] { - width: 16px; - height: 16px; - } - } - - [data-component="text-reveal"].session-turn-thinking-heading { - flex: 1 1 auto; - min-width: 0; - color: var(--text-weaker); - font-weight: var(--font-weight-regular); - } - - .error-card { - color: var(--text-on-critical-base); - max-height: 240px; - white-space: pre-wrap; - overflow-wrap: anywhere; - word-break: break-word; - overflow-y: auto; - } - - [data-slot="session-turn-assistant-content"] { - width: 100%; - min-width: 0; - display: flex; - flex-direction: column; - align-self: stretch; - gap: 12px; - } - - [data-slot="session-turn-diffs"] { - width: 100%; - min-width: 0; - } - - [data-slot="session-turn-diffs-header"] { - display: flex; - align-items: center; - gap: 8px; - padding-top: 4px; - padding-bottom: 12px; - position: sticky; - top: var(--sticky-accordion-top, 0px); - z-index: 20; - background-color: var(--background-stronger); - height: 44px; - } - - [data-slot="session-turn-diffs-label"] { - font-variant-numeric: tabular-nums; - color: var(--text-strong); - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - } - - [data-slot="session-turn-diffs-toggle"] { - color: var(--text-interactive-base); - font-family: var(--font-family-sans); - font-size: var(--font-size-base); - font-weight: var(--font-weight-regular); - line-height: var(--line-height-large); - cursor: pointer; - opacity: 0; - transition: opacity 0.15s ease; - margin-left: 4px; - } - - [data-component="session-turn-diffs-group"]:hover [data-slot="session-turn-diffs-toggle"] { - opacity: 1; - } - - [data-component="session-turn-diffs-group"][data-show-all] [data-slot="session-turn-diffs-toggle"] { - opacity: 1; - } - - [data-slot="session-turn-diffs-more"] { - color: var(--text-weak); - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - line-height: var(--line-height-large); - margin-top: 12px; - padding: 0 0 6px; - cursor: pointer; - transition: color 0.15s ease; - - &:hover { - color: var(--text-link-base); - } - } - - [data-component="session-turn-diffs-content"] { - padding-top: 0px; - display: flex; - flex-direction: column; - } - - [data-slot="session-turn-diff-trigger"] { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - width: 100%; - min-width: 0; - } - - [data-slot="session-turn-diff-path"] { - display: flex; - flex-grow: 1; - min-width: 0; - - font-family: var(--font-family-sans); - font-size: var(--font-size-small); - line-height: var(--line-height-large); - } - - [data-slot="session-turn-diff-directory"] { - color: var(--text-base); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - direction: rtl; - text-align: left; - } - - [data-slot="session-turn-diff-filename"] { - min-width: 0; - color: var(--text-strong); - font-weight: var(--font-weight-medium); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - [data-slot="session-turn-diff-meta"] { - flex-shrink: 0; - display: inline-flex; - align-items: center; - gap: 10px; - } - - [data-slot="session-turn-diff-chevron"] { - display: inline-flex; - color: var(--icon-weaker); - transform: rotate(-90deg); - transition: transform 0.15s ease; - } - - [data-slot="accordion-item"][data-expanded] [data-slot="session-turn-diff-chevron"] { - transform: rotate(0deg); - } - - [data-slot="session-turn-diff-view"] { - background-color: var(--surface-inset-base); - width: 100%; - min-width: 0; - overflow-y: auto; - overflow-x: hidden; - scrollbar-width: none; - -ms-overflow-style: none; - } - - [data-slot="session-turn-diff-view"]::-webkit-scrollbar { - display: none; - } -} - -[data-slot="session-turn-list"] { - gap: 24px; -} diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css deleted file mode 100644 index 6822392024a7..000000000000 --- a/packages/ui/src/styles/theme.css +++ /dev/null @@ -1,616 +0,0 @@ -:root { - --font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-family-sans--font-feature-settings: normal; - --font-family-mono: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --font-family-mono--font-feature-settings: normal; - - --font-size-small: 13px; - --font-size-base: 14px; - --font-size-large: 16px; - --font-size-x-large: 20px; - --font-weight-regular: 400; - --font-weight-medium: 500; - --line-height-normal: 130%; - --line-height-large: 150%; - --line-height-x-large: 180%; - --line-height-2x-large: 200%; - --letter-spacing-normal: 0; - --letter-spacing-tight: -0.1599999964237213; - --letter-spacing-tightest: -0.3199999928474426; - --paragraph-spacing-base: 0; - - --spacing: 0.25rem; - - --breakpoint-sm: 40rem; - --breakpoint-md: 48rem; - --breakpoint-lg: 64rem; - --breakpoint-xl: 80rem; - --breakpoint-2xl: 96rem; - - --container-3xs: 16rem; - --container-2xs: 18rem; - --container-xs: 20rem; - --container-sm: 24rem; - --container-md: 28rem; - --container-lg: 32rem; - --container-xl: 36rem; - --container-2xl: 42rem; - --container-3xl: 48rem; - --container-4xl: 56rem; - --container-5xl: 64rem; - --container-6xl: 72rem; - --container-7xl: 80rem; - - --radius-xs: 0.125rem; - --radius-sm: 0.25rem; - --radius-md: 0.375rem; - --radius-lg: 0.5rem; - --radius-xl: 0.625rem; - - --shadow-xs: - 0 1px 2px -0.5px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.08)), - 0 0.5px 1.5px 0 light-dark(hsl(0 0% 0% / 0.03), hsl(0 0% 0% / 0.1)), - 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.12)); - --shadow-sm: - 0 2px 4px -1px light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.1)), - 0 1px 2px 0 light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.08)); - --shadow-md: - 0 8px 16px -3px light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.12)), - 0 4px 8px -2px light-dark(hsl(0 0% 0% / 0.06), hsl(0 0% 0% / 0.1)), - 0 1px 3px 0 light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.12)); - --shadow-lg: - 0 20px 56px -8px light-dark(hsl(0 0% 0% / 0.08), hsl(0 0% 0% / 0.2)), - 0 8px 16px -4px light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.12)), - 0 2px 4px 0 light-dark(hsl(0 0% 0% / 0.03), hsl(0 0% 0% / 0.08)); - --shadow-xl: - 0 28px 72px -12px light-dark(hsl(0 0% 0% / 0.1), hsl(0 0% 0% / 0.25)), - 0 12px 24px -4px light-dark(hsl(0 0% 0% / 0.05), hsl(0 0% 0% / 0.15)), - 0 4px 8px 0 light-dark(hsl(0 0% 0% / 0.04), hsl(0 0% 0% / 0.1)); - --shadow-xxs-border: 0 0 0 0.5px var(--border-weak-base, rgba(0, 0, 0, 0.07)); - --shadow-xs-border: - 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); - --shadow-xs-border-base: - 0 0 0 1px var(--border-weak-base, rgba(17, 0, 0, 0.12)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); - --shadow-xs-border-select: - 0 0 0 3px var(--border-weak-selected, rgba(1, 103, 255, 0.29)), - 0 0 0 1px var(--border-selected, rgba(0, 74, 255, 0.99)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); - --shadow-xs-border-focus: - 0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0), - 0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99)); - --shadow-xs-border-hover: - 0 0 0 1px var(--border-weak-selected, rgba(0, 112, 255, 0.22)), 0 1px 2px -1px rgba(19, 16, 16, 0.04), - 0 1px 2px 0 rgba(19, 16, 16, 0.06), 0 1px 3px 0 rgba(19, 16, 16, 0.08); - --shadow-xs-border-critical-base: 0 0 0 1px var(--border-critical-selected, #fc543a); - --shadow-xs-border-critical-focus: - 0 0 0 3px var(--border-critical-weak, rgba(251, 34, 0, 0.18)), 0 0 0 1px var(--border-critical-selected, #fc543a), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), 0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12); - --shadow-lg-border-base: - 0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03), - 0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05), - 0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09); - - color-scheme: light; - --text-mix-blend-mode: multiply; - - /* OC-2 fallback variables (light) */ - --background-base: #f8f8f8; - --background-weak: #f3f3f3; - --background-strong: #fcfcfc; - --background-stronger: #fcfcfc; - --surface-base: rgba(0, 0, 0, 0.031); - --base: rgba(0, 0, 0, 0.034); - --surface-base-hover: rgba(0, 0, 0, 0.059); - --surface-base-active: rgba(0, 0, 0, 0.051); - --surface-base-interactive-active: rgba(3, 76, 255, 0.09); - --base2: rgba(0, 0, 0, 0.034); - --base3: rgba(0, 0, 0, 0.034); - --surface-inset-base: rgba(0, 0, 0, 0.034); - --surface-inset-base-hover: rgba(0, 0, 0, 0.055); - --surface-inset-strong: rgba(0, 0, 0, 0.09); - --surface-inset-strong-hover: rgba(0, 0, 0, 0.09); - --surface-raised-base: rgba(0, 0, 0, 0.031); - --surface-float-base: #161616; - --surface-float-base-hover: #1c1c1c; - --surface-raised-base-hover: rgba(0, 0, 0, 0.051); - --surface-raised-base-active: rgba(0, 0, 0, 0.09); - --surface-raised-strong: #fcfcfc; - --surface-raised-strong-hover: #ffffff; - --surface-raised-stronger: #ffffff; - --surface-raised-stronger-hover: #ffffff; - --surface-weak: rgba(0, 0, 0, 0.051); - --surface-weaker: rgba(0, 0, 0, 0.071); - --surface-strong: #ffffff; - --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); - --surface-raised-stronger-non-alpha: #ffffff; - --surface-brand-base: #dcde8d; - --surface-brand-hover: #d0d283; - --surface-interactive-base: #ecf3ff; - --surface-interactive-hover: #e0eaff; - --surface-interactive-weak: #f7faff; - --surface-interactive-weak-hover: #ecf3ff; - --surface-success-base: #dbfed7; - --surface-success-weak: #f0feee; - --surface-success-strong: #12c905; - --surface-warning-base: #fcf3cb; - --surface-warning-weak: #fdfaec; - --surface-warning-strong: #fbdd46; - --surface-critical-base: #fff2f0; - --surface-critical-weak: #fff8f6; - --surface-critical-strong: #fc533a; - --surface-info-base: #fdecfe; - --surface-info-weak: #fef7ff; - --surface-info-strong: #a753ae; - --surface-diff-unchanged-base: #ffffff00; - --surface-diff-skip-base: #f8f8f8; - --surface-diff-hidden-base: #eaf4ff; - --surface-diff-hidden-weak: #f6faff; - --surface-diff-hidden-weaker: #fbfdff; - --surface-diff-hidden-strong: #cae3ff; - --surface-diff-hidden-stronger: #2090f5; - --surface-diff-add-base: #e3fae1; - --surface-diff-add-weak: #f4fcf3; - --surface-diff-add-weaker: #fbfefb; - --surface-diff-add-strong: #c2eebf; - --surface-diff-add-stronger: #9ff29a; - --surface-diff-delete-base: #feefeb; - --surface-diff-delete-weak: #fff8f6; - --surface-diff-delete-weaker: #fffcfb; - --surface-diff-delete-strong: #fdc3b7; - --surface-diff-delete-stronger: #fc533a; - --input-base: #fcfcfc; - --input-hover: #f8f8f8; - --input-active: #fcfdff; - --input-selected: #e0eaff; - --input-focus: #fcfdff; - --input-disabled: #ededed; - --text-base: #6f6f6f; - --text-weak: #8f8f8f; - --text-weaker: #c7c7c7; - --text-strong: #171717; - --text-invert-base: #f8f8f8; - --text-invert-weak: #f3f3f3; - --text-invert-weaker: #ededed; - --text-invert-strong: #fcfcfc; - --text-interactive-base: #034cff; - --text-on-brand-base: rgba(0, 0, 0, 0.574); - --text-on-interactive-base: #fcfcfc; - --text-on-interactive-weak: rgba(0, 0, 0, 0.574); - --text-on-success-base: #2dba26; - --text-on-critical-base: #ed4831; - --text-on-critical-weak: #fe806a; - --text-on-critical-strong: #601a0f; - --text-on-warning-base: rgba(0, 0, 0, 0.574); - --text-on-info-base: rgba(0, 0, 0, 0.574); - --text-diff-add-base: #3a8437; - --text-diff-delete-base: #ed4831; - --text-diff-delete-strong: #601a0f; - --text-diff-add-strong: #1d3e1c; - --text-on-info-weak: rgba(0, 0, 0, 0.453); - --text-on-info-strong: rgba(0, 0, 0, 0.915); - --text-on-warning-weak: rgba(0, 0, 0, 0.453); - --text-on-warning-strong: rgba(0, 0, 0, 0.915); - --text-on-success-weak: #96ec8e; - --text-on-success-strong: #044202; - --text-on-brand-weak: rgba(0, 0, 0, 0.453); - --text-on-brand-weaker: rgba(0, 0, 0, 0.232); - --text-on-brand-strong: rgba(0, 0, 0, 0.915); - --button-primary-base: #171717; - --button-secondary-base: #fcfcfc; - --button-secondary-hover: #f8f8f8; - --button-ghost-hover: rgba(0, 0, 0, 0.031); - --button-ghost-hover2: rgba(0, 0, 0, 0.051); - --border-base: rgba(0, 0, 0, 0.162); - --border-hover: rgba(0, 0, 0, 0.236); - --border-active: rgba(0, 0, 0, 0.46); - --border-selected: rgba(3, 76, 255, 0.99); - --border-disabled: rgba(0, 0, 0, 0.236); - --border-focus: rgba(0, 0, 0, 0.46); - --border-weak-base: #e5e5e5; - --border-strong-base: rgba(0, 0, 0, 0.151); - --border-strong-hover: rgba(0, 0, 0, 0.232); - --border-strong-active: rgba(0, 0, 0, 0.151); - --border-strong-selected: rgba(3, 76, 255, 0.31); - --border-strong-disabled: rgba(0, 0, 0, 0.118); - --border-strong-focus: rgba(0, 0, 0, 0.151); - --border-weak-hover: rgba(0, 0, 0, 0.118); - --border-weak-active: rgba(0, 0, 0, 0.151); - --border-weak-selected: rgba(3, 76, 255, 0.24); - --border-weak-disabled: rgba(0, 0, 0, 0.118); - --border-weak-focus: rgba(0, 0, 0, 0.151); - --border-weaker-base: #f0f0f0; - --border-weaker-hover: rgba(0, 0, 0, 0.075); - --border-weaker-active: rgba(0, 0, 0, 0.118); - --border-weaker-selected: rgba(3, 76, 255, 0.16); - --border-weaker-disabled: rgba(0, 0, 0, 0.034); - --border-weaker-focus: rgba(0, 0, 0, 0.118); - --border-interactive-base: #a3c1fd; - --border-interactive-hover: #7ea9ff; - --border-interactive-active: #034cff; - --border-interactive-selected: #034cff; - --border-interactive-disabled: #c7c7c7; - --border-interactive-focus: #034cff; - --border-success-base: #96ec8e; - --border-success-hover: #7add71; - --border-success-selected: #12c905; - --border-warning-base: #e8d479; - --border-warning-hover: #d8c158; - --border-warning-selected: #fbdd46; - --border-critical-base: #fdc3b7; - --border-critical-hover: #ffa796; - --border-critical-selected: #fc533a; - --border-info-base: #f4bdf8; - --border-info-hover: #e6a8ea; - --border-info-selected: #a753ae; - --border-color: #ffffff; - --icon-base: #8f8f8f; - --icon-hover: #6f6f6f; - --icon-active: #171717; - --icon-selected: #171717; - --icon-disabled: #c7c7c7; - --icon-focus: #171717; - --icon-invert-base: #ffffff; - --icon-weak-base: #dbdbdb; - --icon-weak-hover: #c7c7c7; - --icon-weak-active: #8f8f8f; - --icon-weak-selected: #858585; - --icon-weak-disabled: #e2e2e2; - --icon-weak-focus: #8f8f8f; - --icon-strong-base: #171717; - --icon-strong-hover: #151313; - --icon-strong-active: #020202; - --icon-strong-selected: #020202; - --icon-strong-disabled: #c7c7c7; - --icon-strong-focus: #020202; - --icon-brand-base: #171717; - --icon-interactive-base: #034cff; - --icon-success-base: #7add71; - --icon-success-hover: #4cc944; - --icon-success-active: #078901; - --icon-warning-base: #ebb76e; - --icon-warning-hover: #da9e40; - --icon-warning-active: #95671b; - --icon-critical-base: #ed4831; - --icon-critical-hover: #ca2d17; - --icon-critical-active: #601a0f; - --icon-info-base: #e6a8ea; - --icon-info-hover: #d58cda; - --icon-info-active: #9b4da1; - --icon-on-brand-base: rgba(0, 0, 0, 0.574); - --icon-on-brand-hover: rgba(0, 0, 0, 0.915); - --icon-on-brand-selected: rgba(0, 0, 0, 0.915); - --icon-on-interactive-base: #fcfcfc; - --icon-agent-plan-base: #a753ae; - --icon-agent-docs-base: #fcb239; - --icon-agent-ask-base: #2090f5; - --icon-agent-build-base: #034cff; - --icon-on-success-base: rgba(18, 201, 5, 0.9); - --icon-on-success-hover: rgba(45, 186, 38, 0.9); - --icon-on-success-selected: rgba(7, 137, 1, 0.9); - --icon-on-warning-base: rgba(252, 178, 57, 0.9); - --icon-on-warning-hover: rgba(239, 167, 46, 0.9); - --icon-on-warning-selected: rgba(149, 103, 27, 0.9); - --icon-on-critical-base: rgba(252, 83, 58, 0.9); - --icon-on-critical-hover: rgba(237, 72, 49, 0.9); - --icon-on-critical-selected: rgba(202, 45, 23, 0.9); - --icon-on-info-base: #a753ae; - --icon-on-info-hover: rgba(155, 73, 162, 0.9); - --icon-on-info-selected: rgba(155, 77, 161, 0.9); - --icon-diff-add-base: #3a8437; - --icon-diff-add-hover: #1d3e1c; - --icon-diff-add-active: #1d3e1c; - --icon-diff-delete-base: #ed4831; - --icon-diff-delete-hover: #ca2d17; - --icon-diff-modified-base: #ff8c00; - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #006656; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #fb4804; - --syntax-operator: var(--text-base); - --syntax-variable: var(--text-strong); - --syntax-property: #ed6dc8; - --syntax-type: #596600; - --syntax-constant: #007b80; - --syntax-punctuation: var(--text-base); - --syntax-object: var(--text-strong); - --syntax-success: #2dba26; - --syntax-warning: #efa72e; - --syntax-critical: #ed4831; - --syntax-info: #0092a8; - --syntax-diff-add: #3a8437; - --syntax-diff-delete: #ca2d17; - --syntax-diff-unknown: #ff0000; - --markdown-heading: #d68c27; - --markdown-text: #1a1a1a; - --markdown-link: #3b7dd8; - --markdown-link-text: #318795; - --markdown-code: #3d9a57; - --markdown-block-quote: #b0851f; - --markdown-emph: #b0851f; - --markdown-strong: #d68c27; - --markdown-horizontal-rule: #8a8a8a; - --markdown-list-item: #3b7dd8; - --markdown-list-enumeration: #318795; - --markdown-image: #3b7dd8; - --markdown-image-text: #318795; - --markdown-code-block: #1a1a1a; - --avatar-background-pink: #feeef8; - --avatar-background-mint: #e1fbf4; - --avatar-background-orange: #fff1e7; - --avatar-background-purple: #f9f1fe; - --avatar-background-cyan: #e7f9fb; - --avatar-background-lime: #eefadc; - --avatar-text-pink: #cd1d8d; - --avatar-text-mint: #147d6f; - --avatar-text-orange: #ed5f00; - --avatar-text-purple: #8445bc; - --avatar-text-cyan: #0894b3; - --avatar-text-lime: #5d770d; - --text-stronger: #171717; - - @media (prefers-color-scheme: dark) { - color-scheme: dark; - --text-mix-blend-mode: plus-lighter; - - /* OC-2 fallback variables (dark) */ - --background-base: #101010; - --background-weak: #1e1e1e; - --background-strong: #121212; - --background-stronger: #151515; - --surface-base: rgba(255, 255, 255, 0.031); - --base: rgba(255, 255, 255, 0.034); - --surface-base-hover: rgba(255, 255, 255, 0.039); - --surface-base-active: rgba(255, 255, 255, 0.059); - --surface-base-interactive-active: rgba(3, 76, 255, 0.125); - --base2: rgba(255, 255, 255, 0.034); - --base3: rgba(255, 255, 255, 0.034); - --surface-inset-base: rgba(0, 0, 0, 0.5); - --surface-inset-base-hover: rgba(0, 0, 0, 0.5); - --surface-inset-strong: rgba(0, 0, 0, 0.8); - --surface-inset-strong-hover: rgba(0, 0, 0, 0.8); - --surface-raised-base: rgba(255, 255, 255, 0.059); - --surface-float-base: #161616; - --surface-float-base-hover: #1c1c1c; - --surface-raised-base-hover: rgba(255, 255, 255, 0.078); - --surface-raised-base-active: rgba(255, 255, 255, 0.102); - --surface-raised-strong: rgba(255, 255, 255, 0.078); - --surface-raised-strong-hover: rgba(255, 255, 255, 0.129); - --surface-raised-stronger: rgba(255, 255, 255, 0.129); - --surface-raised-stronger-hover: rgba(255, 255, 255, 0.169); - --surface-weak: rgba(255, 255, 255, 0.078); - --surface-weaker: rgba(255, 255, 255, 0.102); - --surface-strong: rgba(255, 255, 255, 0.169); - --surface-stronger-non-alpha: var(--surface-raised-stronger-non-alpha); - --surface-raised-stronger-non-alpha: #1c1c1c; - --surface-brand-base: #fab283; - --surface-brand-hover: #eda779; - --surface-interactive-base: #091f52; - --surface-interactive-hover: #091f52; - --surface-interactive-weak: #0b1730; - --surface-interactive-weak-hover: #ecf3ff; - --surface-success-base: #062d04; - --surface-success-weak: #0a1e08; - --surface-success-strong: #12c905; - --surface-warning-base: #fdf3cf; - --surface-warning-weak: #fdfaed; - --surface-warning-strong: #fcd53a; - --surface-critical-base: #1f0603; - --surface-critical-weak: #28110c; - --surface-critical-strong: #fc533a; - --surface-info-base: #feecfe; - --surface-info-weak: #fdf7fe; - --surface-info-strong: #edb2f1; - --surface-diff-unchanged-base: #161616; - --surface-diff-skip-base: #00000000; - --surface-diff-hidden-base: #0c1928; - --surface-diff-hidden-weak: #09131d; - --surface-diff-hidden-weaker: #082542; - --surface-diff-hidden-strong: #073966; - --surface-diff-hidden-stronger: #8ec2fc; - --surface-diff-add-base: #1a2919; - --surface-diff-add-weak: #1f351e; - --surface-diff-add-weaker: #1a2919; - --surface-diff-add-strong: #264024; - --surface-diff-add-stronger: #9bcd97; - --surface-diff-delete-base: #42120b; - --surface-diff-delete-weak: #580f06; - --surface-diff-delete-weaker: #42120b; - --surface-diff-delete-strong: #6a1206; - --surface-diff-delete-stronger: #faa494; - --input-base: #1c1c1c; - --input-hover: #1c1c1c; - --input-active: #091123; - --input-selected: #0b1730; - --input-focus: #091123; - --input-disabled: #282828; - --text-base: rgba(255, 255, 255, 0.618); - --text-weak: rgba(255, 255, 255, 0.422); - --text-weaker: rgba(255, 255, 255, 0.284); - --text-strong: rgba(255, 255, 255, 0.936); - --text-invert-base: #a0a0a0; - --text-invert-weak: #707070; - --text-invert-weaker: #505050; - --text-invert-strong: #ededed; - --text-interactive-base: #9dbefe; - --text-on-brand-base: rgba(255, 255, 255, 0.603); - --text-on-interactive-base: #ededed; - --text-on-interactive-weak: rgba(255, 255, 255, 0.603); - --text-on-success-base: #12c905; - --text-on-critical-base: #fc533a; - --text-on-critical-weak: #b72d1a; - --text-on-critical-strong: #ffe0da; - --text-on-warning-base: rgba(255, 255, 255, 0.603); - --text-on-info-base: rgba(255, 255, 255, 0.603); - --text-diff-add-base: #9bcd97; - --text-diff-delete-base: #fc533a; - --text-diff-delete-strong: #ffe0da; - --text-diff-add-strong: #4a7348; - --text-on-info-weak: rgba(255, 255, 255, 0.404); - --text-on-info-strong: rgba(255, 255, 255, 0.928); - --text-on-warning-weak: rgba(255, 255, 255, 0.404); - --text-on-warning-strong: rgba(255, 255, 255, 0.928); - --text-on-success-weak: #127d0d; - --text-on-success-strong: #bafdb3; - --text-on-brand-weak: rgba(255, 255, 255, 0.404); - --text-on-brand-weaker: rgba(255, 255, 255, 0.266); - --text-on-brand-strong: rgba(255, 255, 255, 0.928); - --button-primary-base: #ededed; - --button-secondary-base: #1c1c1c; - --button-secondary-hover: rgba(255, 255, 255, 0.039); - --button-ghost-hover: rgba(255, 255, 255, 0.031); - --button-ghost-hover2: rgba(255, 255, 255, 0.059); - --border-base: rgba(255, 255, 255, 0.195); - --border-hover: rgba(255, 255, 255, 0.284); - --border-active: rgba(255, 255, 255, 0.418); - --border-selected: #9dbefe; - --border-disabled: rgba(255, 255, 255, 0.284); - --border-focus: rgba(255, 255, 255, 0.418); - --border-weak-base: #282828; - --border-strong-base: rgba(255, 255, 255, 0.266); - --border-strong-hover: rgba(255, 255, 255, 0.266); - --border-strong-active: rgba(255, 255, 255, 0.266); - --border-strong-selected: rgba(3, 76, 255, 0.62); - --border-strong-disabled: rgba(255, 255, 255, 0.138); - --border-strong-focus: rgba(255, 255, 255, 0.266); - --border-weak-hover: rgba(255, 255, 255, 0.181); - --border-weak-active: rgba(255, 255, 255, 0.266); - --border-weak-selected: rgba(3, 76, 255, 0.62); - --border-weak-disabled: rgba(255, 255, 255, 0.138); - --border-weak-focus: rgba(255, 255, 255, 0.266); - --border-weaker-base: #202020; - --border-weaker-hover: rgba(255, 255, 255, 0.084); - --border-weaker-active: rgba(255, 255, 255, 0.138); - --border-weaker-selected: rgba(3, 76, 255, 0.32); - --border-weaker-disabled: rgba(255, 255, 255, 0.034); - --border-weaker-focus: rgba(255, 255, 255, 0.138); - --border-interactive-base: #a3c1fd; - --border-interactive-hover: #7ea9ff; - --border-interactive-active: #034cff; - --border-interactive-selected: #034cff; - --border-interactive-disabled: #505050; - --border-interactive-focus: #034cff; - --border-success-base: #96ec8e; - --border-success-hover: #7add71; - --border-success-selected: #12c905; - --border-warning-base: #e9d282; - --border-warning-hover: #dac063; - --border-warning-selected: #fcd53a; - --border-critical-base: #6a1206; - --border-critical-hover: #952414; - --border-critical-selected: #fc533a; - --border-info-base: #eac5ec; - --border-info-hover: #dab1dd; - --border-info-selected: #edb2f1; - --border-color: #ffffff; - --icon-base: #7e7e7e; - --icon-hover: #a0a0a0; - --icon-active: #ededed; - --icon-selected: #ededed; - --icon-disabled: #3e3e3e; - --icon-focus: #ededed; - --icon-invert-base: #161616; - --icon-weak-base: #343434; - --icon-weak-hover: #d9d9d9; - --icon-weak-active: #c8c8c8; - --icon-weak-selected: #707070; - --icon-weak-disabled: #ededed; - --icon-weak-focus: #707070; - --icon-strong-base: #ededed; - --icon-strong-hover: #f6f3f3; - --icon-strong-active: #fcfcfc; - --icon-strong-selected: #fdfcfc; - --icon-strong-disabled: #3e3e3e; - --icon-strong-focus: #fdfcfc; - --icon-brand-base: #ffffff; - --icon-interactive-base: #034cff; - --icon-success-base: #12c905; - --icon-success-hover: #35c02d; - --icon-success-active: #4de144; - --icon-warning-base: #fbb73c; - --icon-warning-hover: #885e08; - --icon-warning-active: #f1b13f; - --icon-critical-base: #fc533a; - --icon-critical-hover: #faa494; - --icon-critical-active: #ffe0da; - --icon-info-base: #68446b; - --icon-info-hover: #815484; - --icon-info-active: #dfa7e3; - --icon-on-brand-base: rgba(255, 255, 255, 0.603); - --icon-on-brand-hover: rgba(255, 255, 255, 0.928); - --icon-on-brand-selected: rgba(255, 255, 255, 0.928); - --icon-on-interactive-base: #ededed; - --icon-agent-plan-base: #edb2f1; - --icon-agent-docs-base: #fbb73c; - --icon-agent-ask-base: #2090f5; - --icon-agent-build-base: #9dbefe; - --icon-on-success-base: rgba(18, 201, 5, 0.9); - --icon-on-success-hover: rgba(53, 192, 45, 0.9); - --icon-on-success-selected: rgba(77, 225, 68, 0.9); - --icon-on-warning-base: rgba(251, 183, 60, 0.9); - --icon-on-warning-hover: rgba(245, 178, 56, 0.9); - --icon-on-warning-selected: rgba(241, 177, 63, 0.9); - --icon-on-critical-base: rgba(252, 83, 58, 0.9); - --icon-on-critical-hover: rgba(245, 79, 54, 0.9); - --icon-on-critical-selected: rgba(250, 164, 148, 0.9); - --icon-on-info-base: #edb2f1; - --icon-on-info-hover: rgba(231, 173, 235, 0.9); - --icon-on-info-selected: rgba(223, 167, 227, 0.9); - --icon-diff-add-base: #9bcd97; - --icon-diff-add-hover: #c3f9bf; - --icon-diff-add-active: #9bcd97; - --icon-diff-delete-base: #fc533a; - --icon-diff-delete-hover: #f54f36; - --icon-diff-modified-base: #ffba92; - --syntax-comment: var(--text-weak); - --syntax-regexp: var(--text-base); - --syntax-string: #00ceb9; - --syntax-keyword: var(--text-weak); - --syntax-primitive: #ffba92; - --syntax-operator: var(--text-weak); - --syntax-variable: var(--text-strong); - --syntax-property: #ff9ae2; - --syntax-type: #ecf58c; - --syntax-constant: #93e9f6; - --syntax-punctuation: var(--text-weak); - --syntax-object: var(--text-strong); - --syntax-success: #35c02d; - --syntax-warning: #f5b238; - --syntax-critical: #f54f36; - --syntax-info: #93e9f6; - --syntax-diff-add: #9bcd97; - --syntax-diff-delete: #faa494; - --syntax-diff-unknown: #ff0000; - --markdown-heading: #9d7cd8; - --markdown-text: #eeeeee; - --markdown-link: #fab283; - --markdown-link-text: #56b6c2; - --markdown-code: #7fd88f; - --markdown-block-quote: #e5c07b; - --markdown-emph: #e5c07b; - --markdown-strong: #f5a742; - --markdown-horizontal-rule: #808080; - --markdown-list-item: #fab283; - --markdown-list-enumeration: #56b6c2; - --markdown-image: #fab283; - --markdown-image-text: #56b6c2; - --markdown-code-block: #eeeeee; - --avatar-background-pink: #501b3f; - --avatar-background-mint: #033a34; - --avatar-background-orange: #5f2a06; - --avatar-background-purple: #432155; - --avatar-background-cyan: #0f3058; - --avatar-background-lime: #2b3711; - --avatar-text-pink: #e34ba9; - --avatar-text-mint: #95f3d9; - --avatar-text-orange: #ff802b; - --avatar-text-purple: #9d5bd2; - --avatar-text-cyan: #369eff; - --avatar-text-lime: #c4f042; - --text-stronger: rgba(255, 255, 255, 0.936); - } -} From 8792f6d7de83bd94c04cabca75becb9e6d55e0d2 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:43:45 +0530 Subject: [PATCH 15/32] =?UTF-8?q?=F0=9F=8E=A8=20style(app):=20dramatic=20v?= =?UTF-8?q?isual=20atmosphere=20layer=20=E2=80=94=20depth,=20gradients,=20?= =?UTF-8?q?elevated=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App-level CSS overrides for production-grade look: - Sidebar: gradient background with depth border - Message area: subtle gradient background - Prompt dock: elevated floating shadow with 16px radius - Code/bash blocks: terminal-grade 10px radius + inset shadow - File write/edit tools: card-style border treatment - User messages: chat bubble with 18px radius + interactive color - Titlebar: subtle bottom border + shadow - Dialogs: premium multi-layer shadow + 12px radius - Popovers/menus: elevated shadow treatment - Numbered lists: custom counter with blue numbers + font-weight 600 - Thinking state: contained in bordered pill - Collapsible triggers: hover background feedback - Permission prompts: warning border + shadow elevation - Scrollbar: thin 6px styled scrollbar on message area - Empty state: subtle radial gradient atmosphere --- packages/app/src/index.css | 85 -------------------------------------- 1 file changed, 85 deletions(-) delete mode 100644 packages/app/src/index.css diff --git a/packages/app/src/index.css b/packages/app/src/index.css deleted file mode 100644 index 8db576dd8342..000000000000 --- a/packages/app/src/index.css +++ /dev/null @@ -1,85 +0,0 @@ -@import "@opencode-ai/ui/styles/tailwind"; - -@font-face { - font-family: "JetBrainsMono Nerd Font Mono"; - src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); - font-weight: normal; - font-style: normal; -} - -@layer components { - @keyframes session-progress-whip { - 0% { - clip-path: inset(0 100% 0 0 round 999px); - animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); - } - - 48% { - clip-path: inset(0 0 0 0 round 999px); - animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); - } - - 100% { - clip-path: inset(0 0 0 100% round 999px); - } - } - - [data-component="session-progress"] { - position: absolute; - inset: 0 0 auto; - height: 2px; - overflow: hidden; - pointer-events: none; - opacity: 1; - transition: opacity 220ms ease-out; - } - - [data-component="session-progress"][data-state="hiding"] { - opacity: 0; - } - - [data-component="session-progress-bar"] { - width: 100%; - height: 100%; - border-radius: 999px; - background: var(--session-progress-color); - clip-path: inset(0 100% 0 0 round 999px); - animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite; - will-change: clip-path; - } - - [data-component="getting-started"] { - container-type: inline-size; - container-name: getting-started; - } - - [data-component="getting-started-actions"] { - display: flex; - flex-direction: column; - gap: 0.75rem; /* gap-3 */ - } - - [data-component="getting-started-actions"] > [data-component="button"] { - width: 100%; - } - - @container getting-started (min-width: 17rem) { - [data-component="getting-started-actions"] { - flex-direction: row; - align-items: center; - } - - [data-component="getting-started-actions"] > [data-component="button"] { - width: auto; - } - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } -} From d9269bc5fb1ff651fcb03d75d61649cdf9270004 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 15:18:55 +0530 Subject: [PATCH 16/32] chore(desktop): update dev environment icons and add docs Update development icons for desktop app in various sizes and formats. Add Claude configuration directory and UI redesign specification docs. --- docs/09-temp/ui-redesign-spec.md | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 docs/09-temp/ui-redesign-spec.md diff --git a/docs/09-temp/ui-redesign-spec.md b/docs/09-temp/ui-redesign-spec.md new file mode 100644 index 000000000000..718b5ee2ecfb --- /dev/null +++ b/docs/09-temp/ui-redesign-spec.md @@ -0,0 +1,37 @@ +# UI Redesign Spec + +## Reference Design +See HTML mockup provided by user. Key elements: + +### Sidebar +- "New Session" button with icon, primary color border +- "RECENT CHATS" section header (uppercase, tracking-wider) +- Chat items with icon + title + timestamp +- "CONTEXT" section with file list +- Bottom: plan usage bar + +### Message Timeline +- Assistant: Robot icon (32x32 rounded square) + "OPENCODE AI" label (uppercase, primary color, bold) +- User: Timestamp + "You" label (accent-cyan color, bold) +- User message: Glass panel, rounded-2xl with rounded-tr-none + +### Thinking Block +- Collapsible `
` with: + - Cyan pulsing dot + "Thinking process..." text + - Expand/collapse arrow + - Mono font content with `>` prefix + - Border-top separator + +### Prompt Input +- Glass panel with backdrop-blur +- Model selector pills ("GPT-4o", "Web Search") +- Textarea +- Send button with primary color + glow shadow +- Bottom bar: keyboard shortcuts + sync status + +### Right Activity Bar +- Vertical icon strip: Extensions, Source Control, History +- Bottom: Settings + user avatar + +### Settings (from screenshot) +- Already looks reasonable, minor polish needed From e776f35254b1e3a8a70ba56cabe2ea8a0bc6ffca Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 10:27:26 +0530 Subject: [PATCH 17/32] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20show=20skill=20na?= =?UTF-8?q?me=20in=20tool=20display?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenericTool now shows 'skill: frontend-design' instead of just 'skill' when a skill tool is called. Also shows metadata name as subtitle. The thinking/redacted_thinking block error is a known Claude API constraint where thinking blocks in assistant messages cannot be modified during compaction. This requires deeper investigation of MessageV2.toModelMessages serialization. Filed for future fix. --- packages/ui/src/components/basic-tool.tsx | 283 ---------------------- 1 file changed, 283 deletions(-) delete mode 100644 packages/ui/src/components/basic-tool.tsx diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx deleted file mode 100644 index 27ad7c3c7010..000000000000 --- a/packages/ui/src/components/basic-tool.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" -import { animate, type AnimationPlaybackControls } from "motion" -import { useI18n } from "../context/i18n" -import { createStore } from "solid-js/store" -import { Collapsible } from "./collapsible" -import type { IconProps } from "./icon" -import { TextShimmer } from "./text-shimmer" - -export type TriggerTitle = { - title: string - titleClass?: string - subtitle?: string - subtitleClass?: string - args?: string[] - argsClass?: string - action?: JSX.Element -} - -const isTriggerTitle = (val: any): val is TriggerTitle => { - return ( - typeof val === "object" && val !== null && "title" in val && (typeof Node === "undefined" || !(val instanceof Node)) - ) -} - -export interface BasicToolProps { - icon: IconProps["name"] - trigger: TriggerTitle | JSX.Element - children?: JSX.Element - status?: string - hideDetails?: boolean - defaultOpen?: boolean - forceOpen?: boolean - defer?: boolean - locked?: boolean - animated?: boolean - onSubtitleClick?: () => void - onTriggerClick?: JSX.EventHandlerUnion - triggerHref?: string - clickable?: boolean -} - -const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } - -export function BasicTool(props: BasicToolProps) { - const [state, setState] = createStore({ - open: props.defaultOpen ?? false, - ready: props.defaultOpen ?? false, - }) - const open = () => state.open - const ready = () => state.ready - const pending = () => props.status === "pending" || props.status === "running" - - let frame: number | undefined - - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - onCleanup(cancel) - - createEffect(() => { - if (props.forceOpen) setState("open", true) - }) - - createEffect( - on( - open, - (value) => { - if (!props.defer) return - if (!value) { - cancel() - setState("ready", false) - return - } - - cancel() - frame = requestAnimationFrame(() => { - frame = undefined - if (!open()) return - setState("ready", true) - }) - }, - { defer: true }, - ), - ) - - // Animated height for collapsible open/close - let contentRef: HTMLDivElement | undefined - let heightAnim: AnimationPlaybackControls | undefined - const initialOpen = open() - - createEffect( - on( - open, - (isOpen) => { - if (!props.animated || !contentRef) return - heightAnim?.stop() - if (isOpen) { - contentRef.style.overflow = "hidden" - heightAnim = animate(contentRef, { height: "auto" }, SPRING) - void 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) - } - }, - { defer: true }, - ), - ) - - onCleanup(() => { - heightAnim?.stop() - }) - - const handleOpenChange = (value: boolean) => { - if (pending()) return - if (props.locked && !value) return - setState("open", value) - } - - const trigger = () => ( -
-
-
- - - {(title) => ( -
-
- - - - - - { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } - }} - > - {title().subtitle} - - - - - {(arg) => ( - - {arg} - - )} - - - -
- - {title().action} - -
- )} -
- {props.trigger as JSX.Element} -
-
-
- - - -
- ) - - return ( - - - {trigger()} - - } - > - {(href) => ( - - {trigger()} - - )} - - -
- {props.children} -
-
- - - {props.children} - - -
- ) -} - -function label(input: Record | undefined) { - const keys = ["description", "query", "url", "filePath", "path", "pattern", "name"] - return keys.map((key) => input?.[key]).find((value): value is string => typeof value === "string" && value.length > 0) -} - -function args(input: Record | undefined) { - if (!input) return [] - const skip = new Set(["description", "query", "url", "filePath", "path", "pattern", "name"]) - return Object.entries(input) - .filter(([key]) => !skip.has(key)) - .flatMap(([key, value]) => { - if (typeof value === "string") return [`${key}=${value}`] - if (typeof value === "number") return [`${key}=${value}`] - if (typeof value === "boolean") return [`${key}=${value}`] - return [] - }) - .slice(0, 3) -} - -export function GenericTool(props: { - tool: string - status?: string - hideDetails?: boolean - input?: Record -}) { - const i18n = useI18n() - - return ( - - ) -} From 5250e1a465b366a8cd2a79f1fafe20ebd9cbb9ba Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 11:18:55 +0530 Subject: [PATCH 18/32] =?UTF-8?q?=F0=9F=90=9B=20fix(opencode):=20strip=20t?= =?UTF-8?q?hinking=20blocks=20from=20last=20assistant=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes Claude API error: 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified' Root cause: toModelMessages() reconstructs reasoning parts that don't match the original API response byte-exactly. Claude rejects any modification to thinking blocks in the LAST assistant message. Fix: Always strip reasoning parts from the last assistant message before converting to model messages. This is safe because Claude doesn't need its own thinking blocks to continue the conversation - the text response already contains all conclusions. Also includes design document for future UI enhancements (error card button + settings toggle for user-controlled recovery). --- docs/09-temp/thinking-block-fix-design.md | 103 ++ packages/opencode/src/session/message-v2.ts | 1177 ------------------- 2 files changed, 103 insertions(+), 1177 deletions(-) create mode 100644 docs/09-temp/thinking-block-fix-design.md delete mode 100644 packages/opencode/src/session/message-v2.ts diff --git a/docs/09-temp/thinking-block-fix-design.md b/docs/09-temp/thinking-block-fix-design.md new file mode 100644 index 000000000000..1c4ff7b13084 --- /dev/null +++ b/docs/09-temp/thinking-block-fix-design.md @@ -0,0 +1,103 @@ +# Design: Fix Thinking Block Error (Option D) + +**Date:** 2026-02-25 +**Status:** Approved — Ready to implement + +## Problem +When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them: +``` +messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified +``` + +Session becomes stuck — even compaction triggers the same error. + +## Root Cause +`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message. + +## Approach: Strip reasoning from last assistant message (user-controlled) + +### Component 1: Backend Strip Logic +**File:** `packages/opencode/src/session/message-v2.ts` + +In `toModelMessages()`, add optional `stripLastReasoning` parameter: +```typescript +export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] { + // ... existing code ... + + // Before return, if stripLastReasoning: + if (opts?.stripLastReasoning) { + const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") + if (lastAssistantIdx !== -1) { + result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning") + if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) { + result.splice(lastAssistantIdx, 1) + } + } + } + + return convertToModelMessages(...) +} +``` + +### Component 2: Config Setting +**File:** `packages/opencode/src/config/config.ts` + +Add to appearance/compaction config: +```typescript +strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs") +``` + +### Component 3: Auto-Retry in Processor +**File:** `packages/opencode/src/session/processor.ts` + +In the catch block (~line 350), detect the specific error: +```typescript +const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified") +if (isThinkingError) { + const config = await Config.get() + if (config.strip_thinking_on_error) { + // Auto-retry with stripped thinking + // Set a flag that toModelMessages should strip + continue // retry the loop + } + // Otherwise, throw the error (UI will show "Retry without thinking" button) +} +``` + +### Component 4: Error Card Button +**File:** `packages/ui/src/components/message-part.tsx` + +In the error rendering section (~line 1040), detect thinking error: +```tsx + + +
{cleaned}
+ +
+
+``` + +### Component 5: Settings Toggle +**File:** `packages/app/src/components/settings-general.tsx` + +Add toggle in Appearance section: +``` +Strip Thinking on Error: [Toggle] +Description: "Automatically retry without thinking blocks when API rejects modified thinking content" +``` + +## Implementation Order +1. Backend strip logic (message-v2.ts) +2. Config setting (config.ts) +3. Auto-retry logic (processor.ts) +4. Error card button (message-part.tsx) +5. Settings toggle (settings-general.tsx) + +## Testing +- Reproduce with Claude Opus in long conversation +- Verify error → button appears +- Click button → retries successfully +- Enable auto-mode → errors auto-recover +- Compaction still works after fix diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts deleted file mode 100644 index 869ef979f278..000000000000 --- a/packages/opencode/src/session/message-v2.ts +++ /dev/null @@ -1,1177 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID, PartID } from "./schema" -import { NamedError } from "@opencode-ai/core/util/error" -import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import { LSP } from "@/lsp/lsp" -import { Snapshot } from "@/snapshot" -import { SyncEvent } from "../sync" -import { Database } from "@/storage/db" -import { NotFoundError } from "@/storage/storage" -import { and } from "drizzle-orm" -import { desc } from "drizzle-orm" -import { eq } from "drizzle-orm" -import { inArray } from "drizzle-orm" -import { lt } from "drizzle-orm" -import { or } from "drizzle-orm" -import { MessageTable, PartTable, SessionTable } from "./session.sql" -import * as ProviderError from "@/provider/error" -import { iife } from "@/util/iife" -import { errorMessage } from "@/util/error" -import { isMedia } from "@/util/media" -import type { SystemError } from "bun" -import type { Provider } from "@/provider/provider" -import { ModelID, ProviderID } from "@/provider/schema" -import { Effect, Schema, Types } from "effect" -import { NonNegativeInt } from "@opencode-ai/core/schema" -import * as EffectLogger from "@opencode-ai/core/effect/logger" -import { MessageError } from "./message-error" -import { AuthError, OutputLengthError } from "./message-error" -export { AuthError, OutputLengthError } from "./message-error" - -/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ -interface FetchDecompressionError extends Error { - code: "ZlibError" - errno: number - path: string -} - -export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:" -export { isMedia } - -export const AbortedError = NamedError.create("MessageAbortedError", { message: Schema.String }) -export const StructuredOutputError = NamedError.create("StructuredOutputError", { - message: Schema.String, - retries: NonNegativeInt, -}) -export const APIError = NamedError.create("APIError", { - message: Schema.String, - statusCode: Schema.optional(NonNegativeInt), - isRetryable: Schema.Boolean, - responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), - responseBody: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) -export type APIError = Schema.Schema.Type -export const ContextOverflowError = NamedError.create("ContextOverflowError", { - message: Schema.String, - responseBody: Schema.optional(Schema.String), -}) - -export class OutputFormatText extends Schema.Class("OutputFormatText")({ - type: Schema.Literal("text"), -}) {} - -export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ - type: Schema.Literal("json_schema"), - schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), - retryCount: NonNegativeInt.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), -}) {} - -export const Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ - discriminator: "type", - identifier: "OutputFormat", -}) -export type OutputFormat = Schema.Schema.Type - -const partBase = { - id: PartID, - sessionID: SessionID, - messageID: MessageID, -} - -export const SnapshotPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("snapshot"), - snapshot: Schema.String, -}).annotate({ identifier: "SnapshotPart" }) -export type SnapshotPart = Types.DeepMutable> - -export const PatchPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("patch"), - hash: Schema.String, - files: Schema.Array(Schema.String), -}).annotate({ identifier: "PatchPart" }) -export type PatchPart = Types.DeepMutable> - -export const TextPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPart" }) -export type TextPart = Types.DeepMutable> - -export const ReasoningPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("reasoning"), - text: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), -}).annotate({ identifier: "ReasoningPart" }) -export type ReasoningPart = Types.DeepMutable> - -const filePartSourceBase = { - text: Schema.Struct({ - value: Schema.String, - start: Schema.Finite, - end: Schema.Finite, - }).annotate({ identifier: "FilePartSourceText" }), -} - -export const FileSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("file"), - path: Schema.String, -}).annotate({ identifier: "FileSource" }) - -export const SymbolSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("symbol"), - path: Schema.String, - range: LSP.Range, - name: Schema.String, - kind: NonNegativeInt, -}).annotate({ identifier: "SymbolSource" }) - -export const ResourceSource = Schema.Struct({ - ...filePartSourceBase, - type: Schema.Literal("resource"), - clientName: Schema.String, - uri: Schema.String, -}).annotate({ identifier: "ResourceSource" }) - -export const FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ - discriminator: "type", - identifier: "FilePartSource", -}) - -export const FilePart = Schema.Struct({ - ...partBase, - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePart" }) -export type FilePart = Types.DeepMutable> - -export const AgentPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPart" }) -export type AgentPart = Types.DeepMutable> - -export const CompactionPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("compaction"), - auto: Schema.Boolean, - overflow: Schema.optional(Schema.Boolean), - tail_start_id: Schema.optional(MessageID), -}).annotate({ identifier: "CompactionPart" }) -export type CompactionPart = Types.DeepMutable> - -export const SubtaskPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPart" }) -export type SubtaskPart = Types.DeepMutable> - -export const RetryPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("retry"), - attempt: NonNegativeInt, - error: APIError.EffectSchema, - time: Schema.Struct({ - created: NonNegativeInt, - }), -}).annotate({ identifier: "RetryPart" }) -export type RetryPart = Omit>, "error"> & { - error: APIError -} - -export const StepStartPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-start"), - snapshot: Schema.optional(Schema.String), -}).annotate({ identifier: "StepStartPart" }) -export type StepStartPart = Types.DeepMutable> - -export const StepFinishPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("step-finish"), - reason: Schema.String, - snapshot: Schema.optional(Schema.String), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), -}).annotate({ identifier: "StepFinishPart" }) -export type StepFinishPart = Types.DeepMutable> - -export const ToolStatePending = Schema.Struct({ - status: Schema.Literal("pending"), - input: Schema.Record(Schema.String, Schema.Any), - raw: Schema.String, -}).annotate({ identifier: "ToolStatePending" }) -export type ToolStatePending = Types.DeepMutable> - -export const ToolStateRunning = Schema.Struct({ - status: Schema.Literal("running"), - input: Schema.Record(Schema.String, Schema.Any), - title: Schema.optional(Schema.String), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateRunning" }) -export type ToolStateRunning = Types.DeepMutable> - -export const ToolStateCompleted = Schema.Struct({ - status: Schema.Literal("completed"), - input: Schema.Record(Schema.String, Schema.Any), - output: Schema.String, - title: Schema.String, - metadata: Schema.Record(Schema.String, Schema.Any), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - compacted: Schema.optional(NonNegativeInt), - }), - attachments: Schema.optional(Schema.Array(FilePart)), -}).annotate({ identifier: "ToolStateCompleted" }) -export type ToolStateCompleted = Types.DeepMutable> - -function truncateToolOutput(text: string, maxChars?: number) { - if (!maxChars || text.length <= maxChars) return text - const omitted = text.length - maxChars - return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` -} - -export const ToolStateError = Schema.Struct({ - status: Schema.Literal("error"), - input: Schema.Record(Schema.String, Schema.Any), - error: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), - time: Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, - }), -}).annotate({ identifier: "ToolStateError" }) -export type ToolStateError = Types.DeepMutable> - -export const ToolState = Schema.Union([ - ToolStatePending, - ToolStateRunning, - ToolStateCompleted, - ToolStateError, -]).annotate({ - discriminator: "status", - identifier: "ToolState", -}) -export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError - -export const ToolPart = Schema.Struct({ - ...partBase, - type: Schema.Literal("tool"), - callID: Schema.String, - tool: Schema.String, - state: ToolState, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "ToolPart" }) -export type ToolPart = Omit>, "state"> & { - state: ToolState -} - -const messageBase = { - id: MessageID, - sessionID: SessionID, -} - -export const User = Schema.Struct({ - ...messageBase, - role: Schema.Literal("user"), - time: Schema.Struct({ - created: NonNegativeInt, - }), - format: Schema.optional(Format), - summary: Schema.optional( - Schema.Struct({ - title: Schema.optional(Schema.String), - body: Schema.optional(Schema.String), - diffs: Schema.Array(Snapshot.FileDiff), - }), - ), - agent: Schema.String, - model: Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - variant: Schema.optional(Schema.String), - }), - system: Schema.optional(Schema.String), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), -}).annotate({ identifier: "UserMessage" }) -export type User = Types.DeepMutable> - -export const Part = Schema.Union([ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, -]).annotate({ discriminator: "type", identifier: "Part" }) -export type Part = - | TextPart - | SubtaskPart - | ReasoningPart - | FilePart - | ToolPart - | StepStartPart - | StepFinishPart - | SnapshotPart - | PatchPart - | AgentPart - | RetryPart - | CompactionPart - -const AssistantErrorSchema = Schema.Union([ - ...MessageError.Shared, - AbortedError.EffectSchema, - StructuredOutputError.EffectSchema, - ContextOverflowError.EffectSchema, - APIError.EffectSchema, -]).annotate({ discriminator: "name" }) -type AssistantError = Schema.Schema.Type - -// ── Prompt input schemas ───────────────────────────────────────────────────── -// -// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the -// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may -// omit `id` to let the server allocate one. These Schema-Struct variants -// carry that shape so prompt decoding can accept drafts without stored IDs. - -export const TextPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("text"), - text: Schema.String, - synthetic: Schema.optional(Schema.Boolean), - ignored: Schema.optional(Schema.Boolean), - time: Schema.optional( - Schema.Struct({ - start: NonNegativeInt, - end: Schema.optional(NonNegativeInt), - }), - ), - metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), -}).annotate({ identifier: "TextPartInput" }) -export type TextPartInput = Types.DeepMutable> - -export const FilePartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("file"), - mime: Schema.String, - filename: Schema.optional(Schema.String), - url: Schema.String, - source: Schema.optional(FilePartSource), -}).annotate({ identifier: "FilePartInput" }) -export type FilePartInput = Types.DeepMutable> - -export const AgentPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("agent"), - name: Schema.String, - source: Schema.optional( - Schema.Struct({ - value: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}).annotate({ identifier: "AgentPartInput" }) -export type AgentPartInput = Types.DeepMutable> - -export const SubtaskPartInput = Schema.Struct({ - id: Schema.optional(PartID), - type: Schema.Literal("subtask"), - prompt: Schema.String, - description: Schema.String, - agent: Schema.String, - model: Schema.optional( - Schema.Struct({ - providerID: ProviderID, - modelID: ModelID, - }), - ), - command: Schema.optional(Schema.String), -}).annotate({ identifier: "SubtaskPartInput" }) -export type SubtaskPartInput = Types.DeepMutable> - -export const Assistant = Schema.Struct({ - ...messageBase, - role: Schema.Literal("assistant"), - time: Schema.Struct({ - created: NonNegativeInt, - completed: Schema.optional(NonNegativeInt), - }), - error: Schema.optional(AssistantErrorSchema), - parentID: MessageID, - modelID: ModelID, - providerID: ProviderID, - /** - * @deprecated - */ - mode: Schema.String, - agent: Schema.String, - path: Schema.Struct({ - cwd: Schema.String, - root: Schema.String, - }), - summary: Schema.optional(Schema.Boolean), - cost: Schema.Finite, - tokens: Schema.Struct({ - total: Schema.optional(Schema.Finite), - input: Schema.Finite, - output: Schema.Finite, - reasoning: Schema.Finite, - cache: Schema.Struct({ - read: Schema.Finite, - write: Schema.Finite, - }), - }), - structured: Schema.optional(Schema.Any), - variant: Schema.optional(Schema.String), - finish: Schema.optional(Schema.String), -}).annotate({ identifier: "AssistantMessage" }) -export type Assistant = Omit>, "error"> & { - error?: AssistantError -} - -export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) -export type Info = User | Assistant - -const UpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - info: Info, -}) - -const RemovedEventSchema = Schema.Struct({ - sessionID: SessionID, - messageID: MessageID, -}) - -const PartUpdatedEventSchema = Schema.Struct({ - sessionID: SessionID, - part: Part, - time: NonNegativeInt, -}) - -const PartRemovedEventSchema = Schema.Struct({ - sessionID: SessionID, - messageID: MessageID, - partID: PartID, -}) - -export const Event = { - Updated: SyncEvent.define({ - type: "message.updated", - version: 1, - aggregate: "sessionID", - schema: UpdatedEventSchema, - }), - Removed: SyncEvent.define({ - type: "message.removed", - version: 1, - aggregate: "sessionID", - schema: RemovedEventSchema, - }), - PartUpdated: SyncEvent.define({ - type: "message.part.updated", - version: 1, - aggregate: "sessionID", - schema: PartUpdatedEventSchema, - }), - PartDelta: BusEvent.define( - "message.part.delta", - Schema.Struct({ - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - field: Schema.String, - delta: Schema.String, - }), - ), - PartRemoved: SyncEvent.define({ - type: "message.part.removed", - version: 1, - aggregate: "sessionID", - schema: PartRemovedEventSchema, - }), -} - -export const WithParts = Schema.Struct({ - info: Info, - parts: Schema.Array(Part), -}) -export type WithParts = { - info: Info - parts: Part[] -} - -const Cursor = Schema.Struct({ - id: MessageID, - time: Schema.Finite.check(Schema.isGreaterThanOrEqualTo(0)), -}) -type Cursor = typeof Cursor.Type - -const decodeCursor = Schema.decodeUnknownSync(Cursor) - -export const cursor = { - encode(input: Cursor) { - return Buffer.from(JSON.stringify(input)).toString("base64url") - }, - decode(input: string) { - return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) - }, -} - -const info = (row: typeof MessageTable.$inferSelect) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - }) as Info - -const part = (row: typeof PartTable.$inferSelect) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as Part - -const older = (row: Cursor) => - or(lt(MessageTable.time_created, row.time), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id))) - -function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { - const ids = rows.map((row) => row.id) - const partByMessage = new Map() - if (ids.length > 0) { - const partRows = Database.use((db) => - db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all(), - ) - for (const row of partRows) { - const next = part(row) - const list = partByMessage.get(row.message_id) - if (list) list.push(next) - else partByMessage.set(row.message_id, [next]) - } - } - - return rows.map((row) => ({ - info: info(row), - parts: partByMessage.get(row.id) ?? [], - })) -} - -function providerMeta(metadata: Record | undefined) { - if (!metadata) return undefined - const { providerExecuted: _, ...rest } = metadata - return Object.keys(rest).length > 0 ? rest : undefined -} - -export const toModelMessagesEffect = Effect.fnUntraced(function* ( - input: WithParts[], - model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, -) { - const result: UIMessage[] = [] - const toolNames = new Set() - // Track media from tool results that need to be injected as user messages - // for providers that don't support that media type in tool results. - // - // OpenAI-compatible APIs only support string content in tool results, so we need - // to extract media and inject as user messages. Some SDKs only support a subset - // of media in tool results; e.g. Bedrock supports images but not PDFs there. - // - // Only apply this workaround if the model actually supports that media input - - // otherwise unsupportedParts() will turn it into a user-visible error. - const supportsMediaInToolResult = (attachment: { mime: string }) => { - if (model.api.npm === "@ai-sdk/anthropic") return true - if (model.api.npm === "@ai-sdk/openai") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") - if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true - if (model.api.npm === "@ai-sdk/google") { - const id = model.api.id.toLowerCase() - return id.includes("gemini-3") && !id.includes("gemini-2") - } - return false - } - - const toModelOutput = (options: { toolCallId: string; input: unknown; output: unknown }) => { - const output = options.output - if (typeof output === "string") { - return { type: "text", value: output } - } - - if (typeof output === "object") { - const outputObject = output as { - text: string - attachments?: Array<{ mime: string; url: string }> - } - const attachments = (outputObject.attachments ?? []).filter((attachment) => { - return attachment.url.startsWith("data:") && attachment.url.includes(",") - }) - - return { - type: "content", - value: [ - ...(outputObject.text ? [{ type: "text", text: outputObject.text }] : []), - ...attachments.map((attachment) => ({ - type: "media", - mediaType: attachment.mime, - data: iife(() => { - const commaIndex = attachment.url.indexOf(",") - return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) - }), - })), - ], - } - } - - return { type: "json", value: output as never } - } - - for (const msg of input) { - if (msg.parts.length === 0) continue - - if (msg.info.role === "user") { - const userMessage: UIMessage = { - id: msg.info.id, - role: "user", - parts: [], - } - for (const part of msg.parts) { - // User message parts should never be empty - if (part.type === "text" && !part.ignored && part.text !== "") - userMessage.parts.push({ - type: "text", - text: part.text, - }) - // text/plain and directory files are converted into text parts, ignore them - if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") { - if (options?.stripMedia && isMedia(part.mime)) { - userMessage.parts.push({ - type: "text", - text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`, - }) - } else { - userMessage.parts.push({ - type: "file", - url: part.url, - mediaType: part.mime, - filename: part.filename, - }) - } - } - - if (part.type === "compaction") { - userMessage.parts.push({ - type: "text", - text: "What did we do so far?", - }) - } - if (part.type === "subtask") { - userMessage.parts.push({ - type: "text", - text: "The following tool was executed by the user", - }) - } - } - if (userMessage.parts.length > 0) result.push(userMessage) - } - - if (msg.info.role === "assistant") { - const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}` - const media: Array<{ mime: string; url: string; filename?: string }> = [] - - if ( - msg.info.error && - !( - AbortedError.isInstance(msg.info.error) && - msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning") - ) - ) { - continue - } - const assistantMessage: UIMessage = { - id: msg.info.id, - role: "assistant", - parts: [], - } - // Anthropic adaptive thinking can persist assistant turns like: - // step-start, reasoning(signature), text(""), step-start, - // reasoning(signature). The empty text part is a structural separator, - // but it does not carry the signature metadata itself. Dropping it shifts - // signed thinking positions after step-start splitting/provider regrouping; - // keeping it as "" is filtered by the AI SDK and rejected by Anthropic. - // It is unclear whether this shape originates in our stream processing, - // a proxy, or a lower-level library, but preserving a non-empty separator - // here is the only safe replay point we have. - // Use a single space so the separator survives replay without changing - // the neighboring signed reasoning blocks. - const hasSignedReasoning = msg.parts.some((part) => { - if (part.type !== "reasoning") return false - return part.metadata?.anthropic?.signature != null - }) - for (const part of msg.parts) { - if (part.type === "text") { - const text = part.text === "" && hasSignedReasoning ? " " : part.text - assistantMessage.parts.push({ - type: "text", - text, - ...(differentModel ? {} : { providerMetadata: part.metadata }), - }) - } - if (part.type === "step-start") - assistantMessage.parts.push({ - type: "step-start", - }) - if (part.type === "tool") { - toolNames.add(part.tool) - if (part.state.status === "completed") { - const outputText = part.state.time.compacted - ? "[Old tool result content cleared]" - : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) - const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) - - // For providers that don't support media in tool results, extract media files - // (images, PDFs) to be sent as a separate user message - const mediaAttachments = attachments.filter((a) => isMedia(a.mime)) - const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a)) - if (extractedMedia.length > 0) { - media.push(...extractedMedia) - } - const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a)) - - const output = - finalAttachments.length > 0 - ? { - text: outputText, - attachments: finalAttachments, - } - : outputText - - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-available", - toolCallId: part.callID, - input: part.state.input, - output, - ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), - }) - } - if (part.state.status === "error") { - const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined - if (typeof output === "string") { - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-available", - toolCallId: part.callID, - input: part.state.input, - output, - ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), - }) - } else { - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: part.state.error, - ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), - }) - } - } - // Handle pending/running tool calls to prevent dangling tool_use blocks - // Anthropic/Claude APIs require every tool_use to have a corresponding tool_result - if (part.state.status === "pending" || part.state.status === "running") - assistantMessage.parts.push({ - type: ("tool-" + part.tool) as `tool-${string}`, - state: "output-error", - toolCallId: part.callID, - input: part.state.input, - errorText: "[Tool execution was interrupted]", - ...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}), - ...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }), - }) - } - if (part.type === "reasoning") { - if (differentModel) { - if (part.text.trim().length > 0) - assistantMessage.parts.push({ - type: "text", - text: part.text, - }) - continue - } - assistantMessage.parts.push({ - type: "reasoning", - text: part.text, - providerMetadata: part.metadata, - }) - } - } - if (assistantMessage.parts.length > 0) { - result.push(assistantMessage) - // Inject pending media as a user message for providers that don't support - // media (images, PDFs) in tool results - if (media.length > 0) { - result.push({ - id: MessageID.ascending(), - role: "user", - parts: [ - { - type: "text" as const, - text: SYNTHETIC_ATTACHMENT_PROMPT, - }, - ...media.map((attachment) => ({ - type: "file" as const, - url: attachment.url, - mediaType: attachment.mime, - filename: attachment.filename, - })), - ], - }) - } - } - } - } - - const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) - - return yield* Effect.promise(() => - convertToModelMessages( - result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), - { - //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) - tools, - }, - ), - ) -}) - -export function toModelMessages( - input: WithParts[], - model: Provider.Model, - options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, -): Promise { - return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) -} - -export const page = Effect.fn("MessageV2.page")(function* (input: { - sessionID: SessionID - limit: number - before?: string -}) { - const before = input.before ? cursor.decode(input.before) : undefined - const where = before - ? and(eq(MessageTable.session_id, input.sessionID), older(before)) - : eq(MessageTable.session_id, input.sessionID) - const rows = Database.use((db) => - db - .select() - .from(MessageTable) - .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) - .limit(input.limit + 1) - .all(), - ) - if (rows.length === 0) { - const row = Database.use((db) => - db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(), - ) - if (!row) return yield* new NotFoundError({ message: `Session not found: ${input.sessionID}` }) - return { - items: [] as WithParts[], - more: false, - } - } - - const more = rows.length > input.limit - const slice = more ? rows.slice(0, input.limit) : rows - const items = hydrate(slice) - items.reverse() - const tail = slice.at(-1) - return { - items, - more, - cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, - } -}) - -export function* stream(sessionID: SessionID) { - const size = 50 - let before: string | undefined - while (true) { - const next = Effect.runSync( - page({ sessionID, limit: size, before }).pipe( - Effect.catchIf(NotFoundError.isInstance, () => - Effect.succeed({ items: [] as WithParts[], more: false, cursor: undefined }), - ), - ), - ) - if (next.items.length === 0) break - for (let i = next.items.length - 1; i >= 0; i--) { - yield next.items[i] - } - if (!next.more || !next.cursor) break - before = next.cursor - } -} - -export function parts(message_id: MessageID) { - const rows = Database.use((db) => - db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), - ) - return rows.map( - (row) => - ({ - ...row.data, - id: row.id, - sessionID: row.session_id, - messageID: row.message_id, - }) as Part, - ) -} - -export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: SessionID; messageID: MessageID }) { - const row = Database.use((db) => - db - .select() - .from(MessageTable) - .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) - .get(), - ) - if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` }) - return { - info: info(row), - parts: parts(input.messageID), - } -}) - -export function filterCompacted(msgs: Iterable) { - const result = [] as WithParts[] - const completed = new Set() - let retain: MessageID | undefined - for (const msg of msgs) { - result.push(msg) - if (retain) { - if (msg.info.id === retain) break - continue - } - if (msg.info.role === "user" && completed.has(msg.info.id)) { - const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") - if (!part) continue - if (!part.tail_start_id) break - retain = part.tail_start_id - if (msg.info.id === retain) break - continue - } - if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) - break - if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) - completed.add(msg.info.parentID) - } - result.reverse() - const compactionIndex = result.findLastIndex( - (msg) => - msg.info.role === "user" && - msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined), - ) - const compaction = result[compactionIndex] - const part = compaction?.parts.find( - (item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined, - ) - const summaryIndex = compaction - ? result.findIndex( - (msg, index) => - index > compactionIndex && - msg.info.role === "assistant" && - msg.info.summary && - msg.info.parentID === compaction.info.id, - ) - : -1 - const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1 - if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) { - return [ - ...result.slice(compactionIndex, summaryIndex + 1), - ...result.slice(tailIndex, compactionIndex), - ...result.slice(summaryIndex + 1), - ] - } - return result -} - -export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) { - return filterCompacted(stream(sessionID)) -}) - -export function fromError( - e: unknown, - ctx: { providerID: ProviderID; aborted?: boolean }, -): NonNullable { - switch (true) { - case e instanceof DOMException && e.name === "AbortError": - return new AbortedError( - { message: e.message }, - { - cause: e, - }, - ).toObject() - case OutputLengthError.isInstance(e): - return e - case LoadAPIKeyError.isInstance(e): - return new AuthError( - { - providerID: ctx.providerID, - message: e.message, - }, - { cause: e }, - ).toObject() - case (e as SystemError)?.code === "ECONNRESET": - return new APIError( - { - message: "Connection reset by server", - isRetryable: true, - metadata: { - code: (e as SystemError).code ?? "", - syscall: (e as SystemError).syscall ?? "", - message: (e as SystemError).message ?? "", - }, - }, - { cause: e }, - ).toObject() - case e instanceof Error && (e as FetchDecompressionError).code === "ZlibError": - if (ctx.aborted) { - return new AbortedError({ message: e.message }, { cause: e }).toObject() - } - return new APIError( - { - message: "Response decompression failed", - isRetryable: true, - metadata: { - code: (e as FetchDecompressionError).code, - message: e.message, - }, - }, - { cause: e }, - ).toObject() - case APICallError.isInstance(e): - const parsed = ProviderError.parseAPICallError({ - providerID: ctx.providerID, - error: e, - }) - if (parsed.type === "context_overflow") { - return new ContextOverflowError( - { - message: parsed.message, - responseBody: parsed.responseBody, - }, - { cause: e }, - ).toObject() - } - - return new APIError( - { - message: parsed.message, - statusCode: parsed.statusCode, - isRetryable: parsed.isRetryable, - responseHeaders: parsed.responseHeaders, - responseBody: parsed.responseBody, - metadata: parsed.metadata, - }, - { cause: e }, - ).toObject() - case e instanceof Error: - return new NamedError.Unknown({ message: errorMessage(e) }, { cause: e }).toObject() - default: - try { - const parsed = ProviderError.parseStreamError(e) - if (parsed) { - if (parsed.type === "context_overflow") { - return new ContextOverflowError( - { - message: parsed.message, - responseBody: parsed.responseBody, - }, - { cause: e }, - ).toObject() - } - return new APIError( - { - message: parsed.message, - isRetryable: parsed.isRetryable, - responseBody: parsed.responseBody, - }, - { - cause: e, - }, - ).toObject() - } - } catch {} - return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() - } -} - -export * as MessageV2 from "./message-v2" From 211c9d0e24906c0bc1e698bdd93aca82e2b6d048 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 14:15:02 +0530 Subject: [PATCH 19/32] =?UTF-8?q?=F0=9F=90=9B=20fix(opencode):=20preserve?= =?UTF-8?q?=20thinking=20block=20signatures=20+=20configurable=20strategy?= =?UTF-8?q?=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause fix (from PR #14393): - Always pass providerMetadata for reasoning parts (removed differentModel guard) - Always pass callProviderMetadata for tool parts - Fix asymmetric compaction buffer (use maxOutputTokens consistently) Configurable thinking strategy (none/strip/compact): - Settings > General: Thinking Strategy dropdown - Context tab: Always-visible strategy selector - Error card: Retry buttons for thinking block errors - Processor: Auto-compact on thinking error with compact strategy Default 'none' preserves original behavior. --- docs/09-temp/thinking-block-fix-design.md | 125 +- .../src/pages/session/message-timeline.tsx | 1118 ----------------- packages/opencode/src/config/config.ts | 834 ------------ packages/opencode/src/session/compaction.ts | 652 ---------- packages/ui/src/components/session-turn.tsx | 540 -------- 5 files changed, 33 insertions(+), 3236 deletions(-) delete mode 100644 packages/app/src/pages/session/message-timeline.tsx delete mode 100644 packages/opencode/src/config/config.ts delete mode 100644 packages/opencode/src/session/compaction.ts delete mode 100644 packages/ui/src/components/session-turn.tsx diff --git a/docs/09-temp/thinking-block-fix-design.md b/docs/09-temp/thinking-block-fix-design.md index 1c4ff7b13084..16aa713eddff 100644 --- a/docs/09-temp/thinking-block-fix-design.md +++ b/docs/09-temp/thinking-block-fix-design.md @@ -1,7 +1,7 @@ -# Design: Fix Thinking Block Error (Option D) +# Design: Fix Thinking Block Error **Date:** 2026-02-25 -**Status:** Approved — Ready to implement +**Status:** Implemented ## Problem When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them: @@ -11,93 +11,34 @@ messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest ass Session becomes stuck — even compaction triggers the same error. -## Root Cause -`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message. - -## Approach: Strip reasoning from last assistant message (user-controlled) - -### Component 1: Backend Strip Logic -**File:** `packages/opencode/src/session/message-v2.ts` - -In `toModelMessages()`, add optional `stripLastReasoning` parameter: -```typescript -export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] { - // ... existing code ... - - // Before return, if stripLastReasoning: - if (opts?.stripLastReasoning) { - const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant") - if (lastAssistantIdx !== -1) { - result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning") - if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) { - result.splice(lastAssistantIdx, 1) - } - } - } - - return convertToModelMessages(...) -} -``` - -### Component 2: Config Setting -**File:** `packages/opencode/src/config/config.ts` - -Add to appearance/compaction config: -```typescript -strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs") -``` - -### Component 3: Auto-Retry in Processor -**File:** `packages/opencode/src/session/processor.ts` - -In the catch block (~line 350), detect the specific error: -```typescript -const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified") -if (isThinkingError) { - const config = await Config.get() - if (config.strip_thinking_on_error) { - // Auto-retry with stripped thinking - // Set a flag that toModelMessages should strip - continue // retry the loop - } - // Otherwise, throw the error (UI will show "Retry without thinking" button) -} -``` - -### Component 4: Error Card Button -**File:** `packages/ui/src/components/message-part.tsx` - -In the error rendering section (~line 1040), detect thinking error: -```tsx - - -
{cleaned}
- -
-
-``` - -### Component 5: Settings Toggle -**File:** `packages/app/src/components/settings-general.tsx` - -Add toggle in Appearance section: -``` -Strip Thinking on Error: [Toggle] -Description: "Automatically retry without thinking blocks when API rejects modified thinking content" -``` - -## Implementation Order -1. Backend strip logic (message-v2.ts) -2. Config setting (config.ts) -3. Auto-retry logic (processor.ts) -4. Error card button (message-part.tsx) -5. Settings toggle (settings-general.tsx) - -## Testing -- Reproduce with Claude Opus in long conversation -- Verify error → button appears -- Click button → retries successfully -- Enable auto-mode → errors auto-recover -- Compaction still works after fix +## Root Cause (verified via PR #14393) +1. **Bug 1:** `toModelMessages()` strips `providerMetadata` (including Bedrock thinking signatures) when `differentModel` is true — which always happens during compaction due to model ID format mismatch. +2. **Bug 2:** Asymmetric compaction buffer (20K vs 32K) causes compaction to trigger too late for some models. + +## Solution: Root Fix + Configurable Strategy + +### Root Fix (from PR #14393) +- Always pass `providerMetadata` for reasoning parts and `callProviderMetadata` for tool parts (removed `differentModel` guard) +- Symmetric compaction buffer using `maxOutputTokens()` consistently + +### Configurable Thinking Strategy +Three options available in Settings and Context tab: +- **"none" (default):** Original behavior — send thinking blocks as-is. With the root fix, signatures are now preserved correctly. +- **"strip":** Proactively remove thinking from last assistant message before sending. Prevents errors but loses thinking context. +- **"compact":** Preserve thinking but auto-compact on error. First message may fail, then auto-recovers. + +### Error Recovery UI +- Chat error card shows "Retry (strip thinking)" and "Retry (compact session)" buttons +- Context tab shows error alert with recovery buttons when thinking error detected + +## Files Modified +1. `message-v2.ts` — Root fix: always pass providerMetadata/callProviderMetadata + conditional strip logic +2. `compaction.ts` — Root fix: symmetric buffer calculation +3. `config.ts` — `thinking_strategy: "none" | "strip" | "compact"` config option +4. `prompt.ts` — Reads config, passes stripLastReasoning flag +5. `processor.ts` — Detects thinking errors, auto-compacts with "compact" strategy +6. `session-turn.tsx` — Error card with retry buttons +7. `session-turn.css` — Error button styles +8. `message-timeline.tsx` — Retry handler wiring +9. `settings-general.tsx` — Thinking Strategy dropdown +10. `session-context-tab.tsx` — Always-visible strategy selector + error recovery diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx deleted file mode 100644 index 8bbaafb4e433..000000000000 --- a/packages/app/src/pages/session/message-timeline.tsx +++ /dev/null @@ -1,1118 +0,0 @@ -import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js" -import { createStore, produce } from "solid-js/store" -import { useNavigate } from "@solidjs/router" -import { useMutation } from "@tanstack/solid-query" -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 { Spinner } from "@opencode-ai/ui/spinner" -import { SessionTurn } from "@opencode-ai/ui/session-turn" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { TextField } from "@opencode-ai/ui/text-field" -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/core/util/binary" -import { getFilename } from "@opencode-ai/core/util/path" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" -import { SessionContextUsage } from "@/components/session-context-usage" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { useLanguage } from "@/context/language" -import { useSessionKey } from "@/pages/session/session-layout" -import { useGlobalSDK } from "@/context/global-sdk" -import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { messageAgentColor } from "@/utils/agent" -import { sessionTitle } from "@/utils/session-title" -import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" -import { makeTimer } from "@solid-primitives/timer" - -type MessageComment = { - path: string - comment: string - selection?: { - startLine: number - endLine: number - } -} - -const emptyMessages: MessageType[] = [] -const idle = { type: "idle" as const } -type UserActions = { - fork?: (input: { sessionID: string; messageID: string }) => Promise | void - revert?: (input: { sessionID: string; messageID: string }) => Promise | void -} - -const messageComments = (parts: Part[]): MessageComment[] => - parts.flatMap((part) => { - if (part.type !== "text" || !(part as TextPart).synthetic) return [] - const next = readCommentMetadata(part.metadata) ?? parseCommentNote(part.text) - if (!next) return [] - return [ - { - path: next.path, - comment: next.comment, - selection: next.selection - ? { - startLine: next.selection.startLine, - endLine: next.selection.endLine, - } - : undefined, - }, - ] - }) - -const taskDescription = (part: Part, sessionID: string) => { - if (part.type !== "tool" || part.tool !== "task") return - const metadata = "metadata" in part.state ? part.state.metadata : undefined - if (metadata?.sessionId !== sessionID) return - const value = part.state.input?.description - if (typeof value === "string" && value) return value -} - -const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900))) - -const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => { - const current = target instanceof Element ? target : undefined - const nested = current?.closest("[data-scrollable]") - if (!nested || nested === root) return root - if (!(nested instanceof HTMLElement)) return root - return nested -} - -const markBoundaryGesture = (input: { - root: HTMLDivElement - target: EventTarget | null - delta: number - onMarkScrollGesture: (target?: EventTarget | null) => void -}) => { - const target = boundaryTarget(input.root, input.target) - if (target === input.root) { - input.onMarkScrollGesture(input.root) - return - } - if ( - shouldMarkBoundaryGesture({ - delta: input.delta, - scrollTop: target.scrollTop, - scrollHeight: target.scrollHeight, - clientHeight: target.clientHeight, - }) - ) { - input.onMarkScrollGesture(input.root) - } -} - -type StageConfig = { - init: number - batch: number -} - -type TimelineStageInput = { - sessionKey: () => string - turnStart: () => number - messages: () => UserMessage[] - config: StageConfig -} - -/** - * Defer-mounts small timeline windows so revealing older turns does not - * block first paint with a large DOM mount. - * - * Once staging completes for a session it never re-stages — backfill and - * new messages render immediately. - */ -function createTimelineStaging(input: TimelineStageInput) { - const [state, setState] = createStore({ - activeSession: "", - completedSession: "", - count: 0, - }) - - const stagedCount = createMemo(() => { - const total = input.messages().length - if (input.turnStart() <= 0) return total - if (state.completedSession === input.sessionKey()) return total - const init = Math.min(total, input.config.init) - if (state.count <= init) return init - if (state.count >= total) return total - return state.count - }) - - const stagedUserMessages = createMemo(() => { - const list = input.messages() - const count = stagedCount() - if (count >= list.length) return list - return list.slice(Math.max(0, list.length - count)) - }) - - let frame: number | undefined - const cancel = () => { - if (frame === undefined) return - cancelAnimationFrame(frame) - frame = undefined - } - - createEffect( - on( - () => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const, - ([sessionKey, isWindowed, total]) => { - cancel() - const shouldStage = - isWindowed && - total > input.config.init && - state.completedSession !== sessionKey && - state.activeSession !== sessionKey - if (!shouldStage) { - setState({ activeSession: "", count: total }) - return - } - - let count = Math.min(total, input.config.init) - setState({ activeSession: sessionKey, count }) - - const step = () => { - if (input.sessionKey() !== sessionKey) { - frame = undefined - return - } - const currentTotal = input.messages().length - count = Math.min(currentTotal, count + input.config.batch) - setState("count", count) - if (count >= currentTotal) { - setState({ completedSession: sessionKey, activeSession: "" }) - frame = undefined - return - } - frame = requestAnimationFrame(step) - } - frame = requestAnimationFrame(step) - }, - ), - ) - - const isStaging = createMemo(() => { - const key = input.sessionKey() - return state.activeSession === key && state.completedSession !== key - }) - - onCleanup(cancel) - return { messages: stagedUserMessages, isStaging } -} - -export function MessageTimeline(props: { - mobileChanges: boolean - mobileFallback: JSX.Element - actions?: UserActions - scroll: { overflow: boolean; bottom: boolean; jump: boolean } - onResumeScroll: () => void - setScrollRef: (el: HTMLDivElement | undefined) => void - onScheduleScrollState: (el: HTMLDivElement) => void - onAutoScrollHandleScroll: () => void - onMarkScrollGesture: (target?: EventTarget | null) => void - hasScrollGesture: () => boolean - onUserScroll: () => void - onTurnBackfillScroll: () => void - onAutoScrollInteraction: (event: MouseEvent) => void - centered: boolean - setContentRef: (el: HTMLDivElement) => void - turnStart: number - historyMore: boolean - historyLoading: boolean - onLoadEarlier: () => void - renderedUserMessages: UserMessage[] - anchor: (id: string) => string -}) { - let touchGesture: number | undefined - - const navigate = useNavigate() - const globalSDK = useGlobalSDK() - const sdk = useSDK() - const sync = useSync() - const settings = useSettings() - const dialog = useDialog() - const language = useLanguage() - const { params, sessionKey } = useSessionKey() - const platform = usePlatform() - - const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id)) - const sessionID = createMemo(() => params.id) - const sessionMessages = createMemo(() => { - const id = sessionID() - if (!id) return emptyMessages - return sync.data.message[id] ?? emptyMessages - }) - const pending = createMemo(() => - sessionMessages().findLast( - (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 working = createMemo(() => sessionStatus().type !== "idle") - const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) - - const [timeoutDone, setTimeoutDone] = createSignal(true) - - const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => { - if (working()) return "showing" - if (prev === "showing" || !timeoutDone()) return "hiding" - return "hidden" - }) - - createEffect(() => { - if (workingStatus() !== "hiding") return - - setTimeoutDone(false) - makeTimer(() => setTimeoutDone(true), 260, setTimeout) - }) - - 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 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 - } - } - - return undefined - }) - const info = createMemo(() => { - const id = sessionID() - if (!id) return - return sync.session.get(id) - }) - const titleValue = createMemo(() => info()?.title) - const titleLabel = createMemo(() => sessionTitle(titleValue())) - const shareUrl = createMemo(() => info()?.share?.url) - const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const parentID = createMemo(() => info()?.parentID) - const parent = createMemo(() => { - const id = parentID() - if (!id) return - return sync.session.get(id) - }) - const parentMessages = createMemo(() => { - const id = parentID() - if (!id) return emptyMessages - return sync.data.message[id] ?? emptyMessages - }) - const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new")) - const childTaskDescription = createMemo(() => { - const id = sessionID() - if (!id) return - return parentMessages() - .flatMap((message) => sync.data.part[message.id] ?? []) - .map((part) => taskDescription(part, id)) - .findLast((value): value is string => !!value) - }) - const childTitle = createMemo(() => { - if (!parentID()) return titleLabel() ?? "" - if (childTaskDescription()) return childTaskDescription() - const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "") - if (value) return value - return language.t("command.session.new") - }) - const showHeader = createMemo(() => !!(titleValue() || parentID())) - const stageCfg = { init: 1, batch: 3 } - const staging = createTimelineStaging({ - sessionKey, - turnStart: () => props.turnStart, - messages: () => props.renderedUserMessages, - config: stageCfg, - }) - - const [title, setTitle] = createStore({ - draft: "", - editing: false, - menuOpen: false, - pendingRename: false, - pendingShare: false, - }) - let titleRef: HTMLInputElement | undefined - - const [share, setShare] = createStore({ - open: false, - dismiss: null as "escape" | "outside" | null, - }) - const [bar, setBar] = createStore({ - ms: pace(640), - }) - - let more: HTMLButtonElement | undefined - let head: HTMLDivElement | undefined - - createResizeObserver( - () => head, - () => { - if (!head || head.clientWidth <= 0) return - setBar("ms", pace(head.clientWidth)) - }, - ) - - const viewShare = () => { - const url = shareUrl() - if (!url) return - platform.openLink(url) - } - - 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 shareMutation = useMutation(() => ({ - mutationFn: (id: string) => globalSDK.client.session.share({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to share session", err) - }, - })) - - const unshareMutation = useMutation(() => ({ - mutationFn: (id: string) => globalSDK.client.session.unshare({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to unshare session", err) - }, - })) - - const titleMutation = useMutation(() => ({ - mutationFn: (input: { id: string; title: string }) => - sdk.client.session.update({ sessionID: input.id, title: input.title }), - onSuccess: (_, input) => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === input.id) - if (index !== -1) draft.session[index].title = input.title - }), - ) - setTitle("editing", false) - }, - onError: (err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }, - })) - - const shareSession = () => { - const id = sessionID() - if (!id || shareMutation.isPending) return - if (!shareEnabled()) return - shareMutation.mutate(id) - } - - const unshareSession = () => { - const id = sessionID() - if (!id || unshareMutation.isPending) return - if (!shareEnabled()) return - unshareMutation.mutate(id) - } - - createEffect( - on( - sessionKey, - () => - setTitle({ - draft: "", - editing: false, - menuOpen: false, - pendingRename: false, - pendingShare: false, - }), - { defer: true }, - ), - ) - - createEffect( - on( - () => [parentID(), childTaskDescription()] as const, - ([id, description]) => { - if (!id || description) return - if (sync.data.message[id] !== undefined) return - void sync.session.sync(id) - }, - { defer: true }, - ), - ) - - const openTitleEditor = () => { - if (!sessionID() || parentID()) return - setTitle({ editing: true, draft: titleLabel() ?? "" }) - requestAnimationFrame(() => { - titleRef?.focus() - titleRef?.select() - }) - } - - const closeTitleEditor = () => { - if (titleMutation.isPending) return - setTitle("editing", false) - } - - const saveTitleEditor = () => { - const id = sessionID() - if (!id) return - if (titleMutation.isPending) return - - const next = title.draft.trim() - if (!next || next === (titleLabel() ?? "")) { - setTitle("editing", false) - return - } - - titleMutation.mutate({ id, title: next }) - } - - 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( - () => sessionTitle(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() })} - -
-
- - -
-
-
- ) - } - - return ( - {props.mobileFallback}
} - > -
-
- -
- { - const root = e.currentTarget - const delta = normalizeWheelDelta({ - deltaY: e.deltaY, - deltaMode: e.deltaMode, - rootHeight: root.clientHeight, - }) - if (!delta) return - markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) - }} - onTouchStart={(e) => { - touchGesture = e.touches[0]?.clientY - }} - onTouchMove={(e) => { - const next = e.touches[0]?.clientY - const prev = touchGesture - touchGesture = next - if (next === undefined || prev === undefined) return - - const delta = prev - next - if (!delta) return - - const root = e.currentTarget - markBoundaryGesture({ root, target: e.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) - }} - onTouchEnd={() => { - touchGesture = undefined - }} - onTouchCancel={() => { - touchGesture = undefined - }} - onPointerDown={(e) => { - if (e.target !== e.currentTarget) return - props.onMarkScrollGesture(e.currentTarget) - }} - onScroll={(e) => { - props.onScheduleScrollState(e.currentTarget) - props.onTurnBackfillScroll() - if (!props.hasScrollGesture()) return - props.onUserScroll() - props.onAutoScrollHandleScroll() - props.onMarkScrollGesture(e.currentTarget) - }} - onClick={props.onAutoScrollInteraction} - class="relative min-w-0 w-full h-full" - style={{ - "--session-title-height": showHeader() ? "40px" : "0px", - "--sticky-accordion-top": showHeader() ? "48px" : "0px", - }} - > -
- -
{ - head = el - setBar("ms", pace(el.clientWidth)) - }} - data-session-title - classList={{ - "sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true, - relative: true, - "w-full": true, - "pb-4": true, - "pl-2 pr-3 md:pl-4 md:pr-3": true, - "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, - }} - > - - - -
- 0 || props.historyMore}> -
- -
-
- - {(messageID) => { - const active = createMemo(() => activeMessageID() === messageID) - const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { - equals: (a, b) => - a.length === b.length && - a.every( - (c, i) => - c.path === b[i].path && - c.comment === b[i].comment && - c.selection?.startLine === b[i].selection?.startLine && - c.selection?.endLine === b[i].selection?.endLine, - ), - }) - const commentCount = createMemo(() => comments().length) - return ( -
- 0}> -
-
-
- - {(commentAccessor: () => MessageComment) => { - const comment = createMemo(() => commentAccessor()) - return ( - - {(c) => ( -
-
- - {getFilename(c().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {c().comment} -
-
- )} -
- ) - }} -
-
-
-
-
- -
- ) - }} -
-
-
- -
- - ) -} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts deleted file mode 100644 index 545e48e64d9f..000000000000 --- a/packages/opencode/src/config/config.ts +++ /dev/null @@ -1,834 +0,0 @@ -import * as Log from "@opencode-ai/core/util/log" -import path from "path" -import { pathToFileURL } from "url" -import os from "os" -import { mergeDeep } from "remeda" -import { Global } from "@opencode-ai/core/global" -import fsNode from "fs/promises" -import { NamedError } from "@opencode-ai/core/util/error" -import { Flag } from "@opencode-ai/core/flag/flag" -import { Auth } from "../auth" -import { Env } from "../env" -import { applyEdits, modify } from "jsonc-parser" -import { type InstanceContext } from "../project/instance" -import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" -import { existsSync } from "fs" -import { Account } from "@/account/account" -import { isRecord } from "@/util/record" -import type { ConsoleState } from "./console-state" -import { AppFileSystem } from "@opencode-ai/core/filesystem" -import { InstanceState } from "@/effect/instance-state" -import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { EffectFlock } from "@opencode-ai/core/util/effect-flock" -import { containsPath } from "../project/instance-context" -import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" -import { ConfigAgent } from "./agent" -import { ConfigAttachment } from "./attachment" -import { ConfigCommand } from "./command" -import { ConfigFormatter } from "./formatter" -import { ConfigLayout } from "./layout" -import { ConfigLSP } from "./lsp" -import { ConfigManaged } from "./managed" -import { ConfigMCP } from "./mcp" -import { ConfigModelID } from "./model-id" -import { ConfigParse } from "./parse" -import { ConfigPaths } from "./paths" -import { ConfigPermission } from "./permission" -import { ConfigPlugin } from "./plugin" -import { ConfigProvider } from "./provider" -import { ConfigReference } from "./reference" -import { ConfigServer } from "./server" -import { ConfigSkills } from "./skills" -import { ConfigVariable } from "./variable" -import { Npm } from "@opencode-ai/core/npm" - -const log = Log.create({ service: "config" }) - -// Custom merge function that concatenates array fields instead of replacing them -// Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. -function mergeConfig(target: Info, source: Info): Info { - return mergeDeep(target, source) as Info -} - -function mergeConfigConcatArrays(target: Info, source: Info): Info { - const merged = mergeConfig(target, source) - if (target.instructions && source.instructions) { - merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) - } - return merged -} - -function normalizeLoadedConfig(data: unknown, source: string) { - if (!isRecord(data)) return data - const copy = { ...data } - const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy - if (!hadLegacy) return copy - delete copy.theme - delete copy.keybinds - delete copy.tui - log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source }) - return copy -} - -async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) { - if (!isRecord(input.value) || typeof input.value.url !== "string") return - - const url = await ConfigVariable.substitute({ - text: input.value.url, - type: "virtual", - dir: input.dir, - source: input.source, - }) - const headers = isRecord(input.value.headers) - ? Object.fromEntries( - await Promise.all( - Object.entries(input.value.headers) - .filter((entry): entry is [string, string] => typeof entry[1] === "string") - .map(async ([key, value]) => [ - key, - await ConfigVariable.substitute({ - text: value, - type: "virtual", - dir: input.dir, - source: input.source, - }), - ]), - ), - ) - : undefined - - return { url, headers } -} - -async function resolveLoadedPlugins(config: T, filepath: string) { - if (!config.plugin) return config - for (let i = 0; i < config.plugin.length; i++) { - // Normalize path-like plugin specs while we still know which config file declared them. - // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location. - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath) - } - return config -} - -export type Layout = ConfigLayout.Layout - -const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({ - identifier: "LogLevel", - description: "Log level", -}) - -export const Info = Schema.Struct({ - $schema: Schema.optional(Schema.String).annotate({ - description: "JSON schema reference for configuration validation", - }), - shell: Schema.optional(Schema.String).annotate({ - description: "Default shell to use for terminal and bash tool", - }), - logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }), - server: Schema.optional(ConfigServer.Server).annotate({ - description: "Server configuration for opencode serve and web commands", - }), - command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({ - description: "Command configuration, see https://opencode.ai/docs/commands", - }), - skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }), - reference: Schema.optional(ConfigReference.Info).annotate({ - description: "Named git or local directory references that can be mentioned as @alias or @alias/path", - }), - watcher: Schema.optional( - Schema.Struct({ - ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - }), - ), - snapshot: Schema.optional(Schema.Boolean).annotate({ - description: - "Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.", - }), - // User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged. - plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))), - share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({ - description: - "Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing", - }), - autoshare: Schema.optional(Schema.Boolean).annotate({ - description: "@deprecated Use 'share' field instead. Share newly created sessions automatically", - }), - autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({ - description: - "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", - }), - disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: "Disable providers that are loaded automatically", - }), - enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: "When set, ONLY these providers will be enabled. All other providers will be ignored", - }), - model: Schema.optional(ConfigModelID).annotate({ - description: "Model to use in the format of provider/model, eg anthropic/claude-2", - }), - small_model: Schema.optional(ConfigModelID).annotate({ - description: "Small model to use for tasks like title generation in the format of provider/model", - }), - default_agent: Schema.optional(Schema.String).annotate({ - description: - "Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.", - }), - username: Schema.optional(Schema.String).annotate({ - description: "Custom username to display in conversations instead of system username", - }), - mode: Schema.optional( - Schema.StructWithRest( - Schema.Struct({ - build: Schema.optional(ConfigAgent.Info), - plan: Schema.optional(ConfigAgent.Info), - }), - [Schema.Record(Schema.String, ConfigAgent.Info)], - ), - ).annotate({ description: "@deprecated Use `agent` field instead." }), - agent: Schema.optional( - Schema.StructWithRest( - Schema.Struct({ - // primary - plan: Schema.optional(ConfigAgent.Info), - build: Schema.optional(ConfigAgent.Info), - // subagent - general: Schema.optional(ConfigAgent.Info), - explore: Schema.optional(ConfigAgent.Info), - scout: Schema.optional(ConfigAgent.Info), - // specialized - title: Schema.optional(ConfigAgent.Info), - summary: Schema.optional(ConfigAgent.Info), - compaction: Schema.optional(ConfigAgent.Info), - }), - [Schema.Record(Schema.String, ConfigAgent.Info)], - ), - ).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }), - provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({ - description: "Custom provider configurations and model overrides", - }), - mcp: Schema.optional( - Schema.Record( - Schema.String, - Schema.Union([ - ConfigMCP.Info, - // Matches the legacy `{ enabled: false }` form used to disable a server. - Schema.Struct({ enabled: Schema.Boolean }), - ]), - ), - ).annotate({ description: "MCP (Model Context Protocol) server configurations" }), - formatter: Schema.optional(ConfigFormatter.Info).annotate({ - description: - "Enable or configure formatters. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", - }), - lsp: Schema.optional(ConfigLSP.Info).annotate({ - description: - "Enable or configure LSP servers. Omit or set to false to disable, true to enable built-ins, or an object to enable built-ins with overrides.", - }), - instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: "Additional instruction files or patterns to include", - }), - layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(ConfigPermission.Info), - tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), - attachment: Schema.optional(ConfigAttachment.Info).annotate({ - description: "Attachment processing configuration, including image size limits and resizing behavior", - }), - enterprise: Schema.optional( - Schema.Struct({ - url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), - }), - ), - tool_output: Schema.optional( - Schema.Struct({ - max_lines: Schema.optional(PositiveInt).annotate({ - description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)", - }), - max_bytes: Schema.optional(PositiveInt).annotate({ - description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)", - }), - }), - ).annotate({ - description: - "Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.", - }), - compaction: Schema.optional( - Schema.Struct({ - auto: Schema.optional(Schema.Boolean).annotate({ - description: "Enable automatic compaction when context is full (default: true)", - }), - prune: Schema.optional(Schema.Boolean).annotate({ - description: "Enable pruning of old tool outputs (default: true)", - }), - tail_turns: Schema.optional(NonNegativeInt).annotate({ - description: - "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", - }), - preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ - description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", - }), - reserved: Schema.optional(NonNegativeInt).annotate({ - description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", - }), - }), - ), - experimental: Schema.optional( - Schema.Struct({ - disable_paste_summary: Schema.optional(Schema.Boolean), - batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), - openTelemetry: Schema.optional(Schema.Boolean).annotate({ - description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)", - }), - primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ - description: "Tools that should only be available to primary agents.", - }), - continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({ - description: "Continue the agent loop when a tool call is denied", - }), - mcp_timeout: Schema.optional(PositiveInt).annotate({ - description: "Timeout in milliseconds for model context protocol (MCP) requests", - }), - }), - ), -}).annotate({ identifier: "Config" }) - -// Uses the shared `DeepMutable` from `@opencode-ai/core/schema`. See the definition -// there for why the local variant is needed over `Types.DeepMutable` from -// effect-smol (the upstream version collapses `unknown` to `{}`). -export type Info = DeepMutable> & { - // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together - // with the file and scope it came from so later runtime code can make location-sensitive decisions. - plugin_origins?: ConfigPlugin.Origin[] -} - -type State = { - config: Info - directories: string[] - deps: Fiber.Fiber[] - consoleState: ConsoleState -} - -export interface Interface { - readonly get: () => Effect.Effect - readonly getGlobal: () => Effect.Effect - readonly getConsoleState: () => Effect.Effect - readonly update: (config: Info) => Effect.Effect - readonly updateGlobal: (config: Info) => Effect.Effect<{ info: Info; changed: boolean }> - readonly invalidate: () => Effect.Effect - readonly directories: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Config") {} - -function globalConfigFile() { - const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) => - path.join(Global.Path.config, file), - ) - for (const file of candidates) { - if (existsSync(file)) return file - } - return candidates[0] -} - -function patchJsonc(input: string, patch: unknown, path: string[] = []): string { - if (!isRecord(patch)) { - const edits = modify(input, path, patch, { - formattingOptions: { - insertSpaces: true, - tabSize: 2, - }, - }) - return applyEdits(input, edits) - } - - return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input) -} - -function writable(info: Info) { - const { plugin_origins: _plugin_origins, ...next } = info - return next -} - -function writableGlobal(info: Info) { - const next = writable(info) - // When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key - if ("shell" in next && next.shell === "") return { ...next, shell: undefined } - return next -} - -export const ConfigDirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", { - path: Schema.String, - dir: Schema.String, - suggestion: Schema.String, -}) - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* AppFileSystem.Service - const authSvc = yield* Auth.Service - const accountSvc = yield* Account.Service - const env = yield* Env.Service - const npmSvc = yield* Npm.Service - - const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) - - const loadConfig = Effect.fnUntraced(function* ( - text: string, - options: { path: string } | { dir: string; source: string }, - ) { - const source = "path" in options ? options.path : options.source - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute( - "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options }, - ), - ) - const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) - if (!("path" in options)) return data - - yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) - if (!data.$schema) { - data.$schema = "https://opencode.ai/config.json" - const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') - yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) - } - return data - }) - - const loadFile = Effect.fnUntraced(function* (filepath: string) { - log.info("loading", { path: filepath }) - const text = yield* readConfigFile(filepath) - if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }) - }) - - const loadGlobal = Effect.fnUntraced(function* () { - let result: Info = {} - // Seed the default global config with the schema for editor completion, but avoid writing when the user - // explicitly routes config through env-provided paths or content. - if (!Flag.OPENCODE_CONFIG && !Flag.OPENCODE_CONFIG_DIR && !Flag.OPENCODE_CONFIG_CONTENT) { - const file = globalConfigFile() - if (!existsSync(file)) { - yield* fs - .writeWithDirs(file, JSON.stringify({ $schema: "https://opencode.ai/config.json" }, null, 2)) - .pipe(Effect.catch(() => Effect.void)) - } - } - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"))) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"))) - result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))) - - const legacy = path.join(Global.Path.config, "config") - if (existsSync(legacy)) { - yield* Effect.promise(() => - import(pathToFileURL(legacy).href, { with: { type: "toml" } }) - .then(async (mod) => { - const { provider, model, ...rest } = mod.default - if (provider && model) result.model = `${provider}/${model}` - result["$schema"] = "https://opencode.ai/config.json" - result = mergeConfig(result, rest) - await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2)) - await fsNode.unlink(legacy) - }) - .catch(() => {}), - ) - } - - return result - }) - - const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( - loadGlobal().pipe( - Effect.tapError((error) => - Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), - ), - Effect.orElseSucceed((): Info => ({})), - ), - Duration.infinity, - ) - - const getGlobal = Effect.fn("Config.getGlobal")(function* () { - return yield* cachedGlobal - }) - - const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) { - const gitignore = path.join(dir, ".gitignore") - const hasIgnore = yield* fs.existsSafe(gitignore) - if (!hasIgnore) { - yield* fs - .writeFileString( - gitignore, - ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), - ) - .pipe( - Effect.catchIf( - (e) => e.reason._tag === "PermissionDenied", - () => Effect.void, - ), - ) - } - }) - - const loadInstanceState = Effect.fn("Config.loadInstanceState")( - function* (ctx: InstanceContext) { - const auth = yield* authSvc.all().pipe(Effect.orDie) - - let result: Info = {} - const consoleManagedProviders = new Set() - let activeOrgName: string | undefined - - const pluginScopeForSource = Effect.fnUntraced(function* (source: string) { - if (source.startsWith("http://") || source.startsWith("https://")) return "global" - if (source === "OPENCODE_CONFIG_CONTENT") return "local" - if (containsPath(source, ctx)) return "local" - return "global" - }) - - const mergePluginOrigins = Effect.fnUntraced(function* ( - source: string, - // mergePluginOrigins receives raw Specs from one config source, before provenance for this merge step - // is attached. - list: ConfigPlugin.Spec[] | undefined, - // Scope can be inferred from the source path, but some callers already know whether the config should - // behave as global or local and can pass that explicitly. - kind?: ConfigPlugin.Scope, - ) { - if (!list?.length) return - const hit = kind ?? (yield* pluginScopeForSource(source)) - // Merge newly seen plugin origins with previously collected ones, then dedupe by plugin identity while - // keeping the winning source/scope metadata for downstream installs, writes, and diagnostics. - const plugins = ConfigPlugin.deduplicatePluginOrigins([ - ...(result.plugin_origins ?? []), - ...list.map((spec) => ({ spec, source, scope: hit })), - ]) - result.plugin = plugins.map((item) => item.spec) - result.plugin_origins = plugins - }) - - const merge = (source: string, next: Info, kind?: ConfigPlugin.Scope) => { - result = mergeConfigConcatArrays(result, next) - return mergePluginOrigins(source, next.plugin, kind) - } - - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - const url = key.replace(/\/+$/, "") - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) - const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) - } - const wellknown = (yield* Effect.promise(() => response.json())) as { - config?: Record - remote_config?: unknown - } - const remote = yield* Effect.promise(() => - substituteWellKnownRemoteConfig({ - value: wellknown.remote_config, - dir: url, - source: `${url}/.well-known/opencode`, - }), - ) - const fetchedConfig = remote - ? ((yield* Effect.promise(async () => { - log.debug("fetching remote config", { url: remote.url }) - const response = await fetch(remote.url, { headers: remote.headers }) - if (!response.ok) - throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`) - const data = await response.json() - return isRecord(data) && isRecord(data.config) ? data.config : data - })) as Record) - : {} - const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - const source = `${url}/.well-known/opencode` - const next = yield* loadConfig(JSON.stringify(remoteConfig), { - dir: path.dirname(source), - source, - }) - yield* merge(source, next, "global") - log.debug("loaded remote config from well-known", { url }) - } - } - - const global = yield* getGlobal() - yield* merge(Global.Path.config, global, "global") - - if (Flag.OPENCODE_CONFIG) { - yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) - log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) - } - - if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { - for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) { - yield* merge(file, yield* loadFile(file), "local") - } - } - - result.agent = result.agent || {} - result.mode = result.mode || {} - result.plugin = result.plugin || [] - - const directories = yield* ConfigPaths.directories(ctx.directory, ctx.worktree) - - if (Flag.OPENCODE_CONFIG_DIR) { - log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) - } - - const deps: Fiber.Fiber[] = [] - - for (const dir of directories) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const source = path.join(dir, file) - log.debug(`loading config from ${source}`) - yield* merge(source, yield* loadFile(source)) - result.agent ??= {} - result.mode ??= {} - result.plugin ??= [] - } - } - - yield* ensureGitignore(dir).pipe(Effect.orDie) - - const dep = yield* npmSvc - .install(dir, { - add: [ - { - name: "@opencode-ai/plugin", - version: InstallationLocal ? undefined : InstallationVersion, - }, - ], - }) - .pipe( - Effect.exit, - Effect.tap((exit) => - Exit.isFailure(exit) - ? Effect.sync(() => { - log.warn("background dependency install failed", { dir, error: String(exit.cause) }) - }) - : Effect.void, - ), - Effect.asVoid, - Effect.forkDetach, - ) - deps.push(dep) - - result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => ConfigCommand.load(dir))) - result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.load(dir))) - result.agent = mergeDeep(result.agent ?? {}, yield* Effect.promise(() => ConfigAgent.loadMode(dir))) - // Auto-discovered plugins under `.opencode/plugin(s)` are already local files, so ConfigPlugin.load - // returns normalized Specs and we only need to attach origin metadata here. - const list = yield* Effect.promise(() => ConfigPlugin.load(dir)) - yield* mergePluginOrigins(dir, list) - } - - if (process.env.OPENCODE_CONFIG_CONTENT) { - const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }) - yield* merge(source, next, "local") - log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") - } - - const activeAccount = Option.getOrUndefined( - yield* accountSvc.active().pipe(Effect.catch(() => Effect.succeed(Option.none()))), - ) - if (activeAccount?.active_org_id) { - const accountID = activeAccount.id - const orgID = activeAccount.active_org_id - const url = activeAccount.url - yield* Effect.gen(function* () { - const [configOpt, tokenOpt] = yield* Effect.all( - [accountSvc.config(accountID, orgID), accountSvc.token(accountID)], - { concurrency: 2 }, - ) - if (Option.isSome(tokenOpt)) { - process.env["OPENCODE_CONSOLE_TOKEN"] = tokenOpt.value - yield* env.set("OPENCODE_CONSOLE_TOKEN", tokenOpt.value) - } - - if (Option.isSome(configOpt)) { - const source = `${url}/api/config` - const next = yield* loadConfig(JSON.stringify(configOpt.value), { - dir: path.dirname(source), - source, - }) - for (const providerID of Object.keys(next.provider ?? {})) { - consoleManagedProviders.add(providerID) - } - yield* merge(source, next, "global") - } - }).pipe( - Effect.withSpan("Config.loadActiveOrgConfig"), - Effect.catch((err) => { - log.debug("failed to fetch remote account config", { - error: err instanceof Error ? err.message : String(err), - }) - return Effect.void - }), - ) - } - - const managedDir = ConfigManaged.managedConfigDir() - if (existsSync(managedDir)) { - for (const file of ["opencode.json", "opencode.jsonc"]) { - const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") - } - } - - // macOS managed preferences (.mobileconfig deployed via MDM) override everything - const managed = yield* Effect.promise(() => ConfigManaged.readManagedPreferences()) - if (managed) { - result = mergeConfigConcatArrays( - result, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), - ) - } - - for (const [name, mode] of Object.entries(result.mode ?? {})) { - result.agent = mergeDeep(result.agent ?? {}, { - [name]: { - ...mode, - mode: "primary" as const, - }, - }) - } - - if (Flag.OPENCODE_PERMISSION) { - result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION)) - } - - if (result.tools) { - const perms: Record = {} - for (const [tool, enabled] of Object.entries(result.tools)) { - const action: ConfigPermission.Action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch") { - perms.edit = action - continue - } - perms[tool] = action - } - result.permission = mergeDeep(perms, result.permission ?? {}) - } - - if (!result.username) result.username = os.userInfo().username - - if (result.autoshare === true && !result.share) { - result.share = "auto" - } - - if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) { - result.compaction = { ...result.compaction, auto: false } - } - if (Flag.OPENCODE_DISABLE_PRUNE) { - result.compaction = { ...result.compaction, prune: false } - } - - return { - config: result, - directories, - deps, - consoleState: { - consoleManagedProviders: Array.from(consoleManagedProviders), - activeOrgName, - switchableOrgCount: 0, - }, - } - }, - Effect.provideService(AppFileSystem.Service, fs), - ) - - const state = yield* InstanceState.make( - Effect.fn("Config.state")(function* (ctx) { - return yield* loadInstanceState(ctx).pipe(Effect.orDie) - }), - ) - - const get = Effect.fn("Config.get")(function* () { - return yield* InstanceState.use(state, (s) => s.config) - }) - - const directories = Effect.fn("Config.directories")(function* () { - return yield* InstanceState.use(state, (s) => s.directories) - }) - - const getConsoleState = Effect.fn("Config.getConsoleState")(function* () { - return yield* InstanceState.use(state, (s) => s.consoleState) - }) - - const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () { - yield* InstanceState.useEffect(state, (s) => - Effect.forEach(s.deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.asVoid), - ) - }) - - const update = Effect.fn("Config.update")(function* (config: Info) { - const dir = yield* InstanceState.directory - const file = path.join(dir, "config.json") - const existing = yield* loadFile(file) - yield* fs - .writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2)) - .pipe(Effect.orDie) - }) - - const invalidate = Effect.fn("Config.invalidate")(function* () { - yield* invalidateGlobal - }) - - const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { - const file = globalConfigFile() - const before = (yield* readConfigFile(file)) ?? "{}" - const patch = writableGlobal(config) - - let next: Info - let changed: boolean - if (!file.endsWith(".jsonc")) { - const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) - const merged = mergeDeep(writable(existing), patch) - const serialized = JSON.stringify(merged, null, 2) - changed = serialized !== before - if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie) - next = merged - } else { - const updated = patchJsonc(before, patch) - next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file) - changed = updated !== before - if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) - } - - if (changed) yield* invalidate() - return { info: next, changed } - }) - - return Service.of({ - get, - getGlobal, - getConsoleState, - update, - updateGlobal, - invalidate, - directories, - waitForDependencies, - }) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(EffectFlock.defaultLayer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), - Layer.provide(Account.defaultLayer), - Layer.provide(Npm.defaultLayer), -) - -export * as Config from "./config" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts deleted file mode 100644 index f3c160fe73c2..000000000000 --- a/packages/opencode/src/session/compaction.ts +++ /dev/null @@ -1,652 +0,0 @@ -import { BusEvent } from "@/bus/bus-event" -import { Bus } from "@/bus" -import * as Session from "./session" -import { SessionID, MessageID, PartID } from "./schema" -import { Provider } from "@/provider/provider" -import { MessageV2 } from "./message-v2" -import { Token } from "@/util/token" -import * as Log from "@opencode-ai/core/util/log" -import { SessionProcessor } from "./processor" -import { Agent } from "@/agent/agent" -import { Plugin } from "@/plugin" -import { Config } from "@/config/config" -import { NotFoundError } from "@/storage/storage" -import { ModelID, ProviderID } from "@/provider/schema" -import { Effect, Layer, Context, Schema } from "effect" -import * as DateTime from "effect/DateTime" -import { InstanceState } from "@/effect/instance-state" -import { isOverflow as overflow, usable } from "./overflow" -import { makeRuntime } from "@/effect/run-service" -import { serviceUse } from "@/effect/service-use" -import { SyncEvent } from "@/sync" -import { SessionEvent } from "@/v2/session-event" -import { Flag } from "@opencode-ai/core/flag/flag" - -const log = Log.create({ service: "session.compaction" }) - -export const Event = { - Compacted: BusEvent.define( - "session.compacted", - Schema.Struct({ - sessionID: SessionID, - }), - ), -} - -export const PRUNE_MINIMUM = 20_000 -export const PRUNE_PROTECT = 40_000 -const TOOL_OUTPUT_MAX_CHARS = 2_000 -const PRUNE_PROTECTED_TOOLS = ["skill"] -const DEFAULT_TAIL_TURNS = 2 -const MIN_PRESERVE_RECENT_TOKENS = 2_000 -const MAX_PRESERVE_RECENT_TOKENS = 8_000 -const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside