From 998848ec0152f88d26c8de9cc0b51c2f18a10e77 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 03:54:35 +0530 Subject: [PATCH 01/31] 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 | 849 ------------------ packages/app/src/components/terminal.tsx | 667 -------------- packages/app/src/context/settings.tsx | 8 + 3 files changed, 8 insertions(+), 1516 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 3970f5c21081..000000000000 --- a/packages/app/src/components/settings-general.tsx +++ /dev/null @@ -1,849 +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 { useDialog } from "@opencode-ai/ui/context/dialog" -import { showToast } from "@/utils/toast" -import { useParams } from "@solidjs/router" -import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" -import { usePlatform, type DisplayBackend } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" -import { useServerSDK } from "@/context/server-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 dialog = useDialog() - 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 serverSync = useServerSync() - const globalSdk = useServerSDK() - - 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 }, - ) - - const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => (desktop() && platform.getPinchZoomEnabled ? true : false), - () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), - { initialValue: false }, - ) - - onMount(() => { - void theme.loadThemes() - }) - - const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync.data.config.shell ?? "") - - const shellOptions = createMemo(() => { - const list = shells.latest - const current = serverSync.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 onPinchZoomChange = (checked: boolean) => { - setPinchZoom(checked) - const update = platform.setPinchZoomEnabled?.(checked) - if (!update) return - void update.catch(() => setPinchZoom(!checked)) - } - - 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 - serverSync.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)} - /> -
-
- - -
- { - settings.general.setNewLayoutDesigns(checked) - if (!checked) return - void import("@/components/settings-v2").then((module) => { - dialog.show(() => ) - }) - }} - /> -
-
-
-
- ) - - const AdvancedSection = () => ( -
-

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

- - - -
- settings.general.setShowFileTree(checked)} - /> -
-
- - -
- settings.general.setShowNavigation(checked)} - /> -
-
- - -
- settings.general.setShowSearch(checked)} - /> -
-
- - -
- settings.general.setShowStatus(checked)} - /> -
-
- - -
- settings.general.setShowCustomAgents(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 historyLoader = createSessionHistoryLoader({ - sessionID: () => params.id, - 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 (!historyMore()) return - - void historyLoader.loadAndReveal() - }) - } - - createEffect( - on( - () => - [ - params.id, - messagesReady(), - historyMore(), - historyLoading(), - autoScroll.userScrolled(), - visibleUserMessages().length, - ] as const, - ([id, ready, more, loading, scrolled]) => { - if (!id || !ready || loading || scrolled) return - if (!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, - serverSync, - 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), - currentMessageId: () => store.messageId, - pendingMessage: () => ui.pendingMessage, - setPendingMessage: (value) => setUi("pendingMessage", value), - setActiveMessage, - autoScroll, - scroller: () => scroller, - anchor, - revealMessage: (id) => revealMessage(id), - 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) - }) - - useUsageExceededDialogs() - - const composerRegion = (placement: "dock" | "inline") => ( - { - 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 - }} - /> - ) - - 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")} - - - - - -
-
- - -
- {reviewContent({ - diffStyle: "unified", - classes: { - root: "pb-8", - header: "px-4", - container: "px-4", - }, - loadingClass: "px-4 py-4 text-text-weak", - emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6", - })} -
-
- - - - !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled() - } - centered={centered()} - setContentRef={(el) => { - content = el - autoScroll.contentRef(el) - - const root = scroller - if (root) scheduleScrollState(root) - }} - historyShift={historyLoader.shift()} - userMessages={historyLoader.userMessages()} - anchor={anchor} - setRevealMessage={(fn) => { - revealMessage = fn - }} - /> - - - - }> - {composerRegion("inline")} - - -
-
- - {composerRegion("dock")} - - -
size.start()}> - { - size.touch() - layout.session.resize(width) - }} - /> -
-
-
- - -
- - -
- ) -} From 94c0f4981f47c5fd1210696daff6aa7ba09b032d Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 05:38:49 +0530 Subject: [PATCH 06/31] 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 2b1cd1826c04..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 "@/utils/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 6940ab0173db291f4f00aef49df571973df284f4 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 06:09:58 +0530 Subject: [PATCH 07/31] 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 | 2193 ------------------ 1 file changed, 2193 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 8e4913b1f5b0..000000000000 --- a/packages/app/src/components/prompt-input.tsx +++ /dev/null @@ -1,2193 +0,0 @@ -import { useFilteredList } from "@opencode-ai/ui/hooks" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { - createEffect, - on, - Component, - splitProps, - For, - Show, - onCleanup, - createMemo, - createSignal, - createResource, - Switch, - Match, - type ComponentProps, - type JSX, -} from "solid-js" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -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 { useNavigate } from "@solidjs/router" -import { useSDK } from "@/context/sdk" -import { useServer } from "@/context/server" -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, type IconProps } 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 { useSettings } from "@/context/settings" -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/server-sync" -import { pathKey } from "@/utils/path-key" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { displayName } from "@/pages/layout/helpers" - -interface PromptInputProps { - class?: string - variant?: "dock" | "new-session" - 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 - -export const PromptInput: Component = (props) => { - const sdk = useSDK() - const navigate = useNavigate() - const queryOptions = useQueryOptions() - - const sync = useSync() - const local = useLocal() - const files = useFile() - const prompt = usePrompt() - const layout = useLayout() - const server = useServer() - const comments = useComments() - const dialog = useDialog() - const providers = useProviders() - const command = useCommand() - const permission = usePermission() - const language = useLanguage() - const platform = usePlatform() - const settings = useSettings() - const { params, tabs, view } = useSessionLayout() - let editorRef!: HTMLDivElement - let fileInputRef: HTMLInputElement | undefined - let scrollRef!: HTMLDivElement - let slashPopoverRef!: HTMLDivElement - let projectSearchRef: HTMLInputElement | undefined - - 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 - variantOpen: boolean - }>({ - popover: null, - historyIndex: -1, - savedPrompt: null as PromptHistoryEntry | null, - placeholder: Math.floor(Math.random() * EXAMPLES.length), - draggingType: null, - mode: "normal", - applyingHistory: false, - variantOpen: false, - }) - const [picker, setPicker] = createStore({ - projectOpen: false, - projectSearch: "", - }) - - 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 textContent = (editorRef.textContent ?? "").replace(/\u200B/g, "") - const shouldReset = - textContent.length === 0 && rawText.replace(/\n/g, "").length === 0 && !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 fileAttachmentInput = () => ( - (fileInputRef = el)} - type="file" - multiple - accept={ACCEPTED_FILE_TYPES.join(",")} - class="hidden" - onChange={(e) => { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - ) - - const variants = createMemo(() => ["default", ...local.model.variant.list()]) - // Check provider variants directly: `variants` also includes the UI-only default option. - const showVariantControl = createMemo(() => local.model.variant.list().length > 0) - 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, - ) - - const designPlaceholder = () => { - if (store.mode === "shell") return placeholder() - return "Ask anything, / for commands, @ for context..." - } - - const modelControlState = createMemo(() => ({ - loading: providersLoading(), - paid: providers.paid().length > 0, - title: language.t("command.model.choose"), - keybind: command.keybind("model.choose"), - model: local.model, - providerID: local.model.current()?.provider?.id, - modelName: local.model.current()?.name ?? language.t("dialog.model.select.title"), - style: control(), - onClose: restoreFocus, - onUnpaidClick: () => { - void import("@/components/dialog-select-model-unpaid").then((x) => { - dialog.show(() => ) - }) - }, - })) - - const newSession = () => props.variant === "new-session" - const projects = createMemo(() => layout.projects.list()) - const projectForDirectory = (directory: string | undefined) => { - if (!directory) return - const key = pathKey(directory) - return projects().find( - (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), - ) - } - const selectedProject = createMemo(() => projectForDirectory(sdk.directory)) - const projectResults = createMemo(() => { - const search = picker.projectSearch.trim().toLowerCase() - if (!search) return projects() - return projects().filter((project) => displayName(project).toLowerCase().includes(search)) - }) - const showAgentControl = createMemo(() => settings.general.showCustomAgents() && agentNames().length > 0) - const selectProject = (worktree: string) => { - setPicker({ - projectOpen: false, - projectSearch: "", - }) - if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { - restoreFocus() - return - } - layout.projects.open(worktree) - server.projects.touch(worktree) - navigate(`/${base64Encode(worktree)}/session`) - } - const addProject = async () => { - const conn = server.current - if (!conn) return - const select = (result: string | string[] | null) => { - const directory = Array.isArray(result) ? result[0] : result - if (!directory) return - selectProject(directory) - } - if (platform.openDirectoryPickerDialog && server.isLocal()) { - select(await platform.openDirectoryPickerDialog({ title: language.t("command.project.open") })) - return - } - void import("@/components/dialog-select-directory").then((x) => { - dialog.show( - () => , - () => select(null), - ) - }) - } - - const projectPickerState = createMemo(() => ({ - open: picker.projectOpen, - trigger: { - action: "prompt-project", - icon: "folder", - label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), - class: "max-w-[203px]", - style: control(), - onPress: () => setPicker("projectOpen", true), - }, - search: picker.projectSearch, - searchPlaceholder: language.t("session.new.project.search"), - clearLabel: language.t("common.clear"), - items: projectResults().map((project) => ({ - icon: "folder", - label: displayName(project), - selected: selectedProject()?.worktree === project.worktree, - onSelect: () => selectProject(project.worktree), - })), - action: { - icon: "plus", - label: language.t("session.new.project.add"), - onSelect: () => { - setPicker("projectOpen", false) - void addProject() - }, - }, - onOpenChange: (open) => { - setPicker("projectOpen", open) - if (open) requestAnimationFrame(() => projectSearchRef?.focus()) - }, - onSearchInput: (value) => setPicker("projectSearch", value), - onSearchClear: () => setPicker("projectSearch", ""), - searchRef: (el) => (projectSearchRef = el), - })) - const agentControlState = createMemo(() => ({ - title: language.t("command.agent.cycle"), - keybind: command.keybind("agent.cycle"), - options: agentNames(), - current: local.agent.current()?.name ?? "", - style: control(), - onSelect: (value) => { - local.agent.set(value) - restoreFocus() - }, - })) - const newProjectTriggerState = createMemo(() => ({ - action: "prompt-project", - icon: "folder-add-left", - label: language.t("session.new.project.new"), - class: "max-w-[160px]", - style: control(), - onPress: () => void addProject(), - })) - - 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-"]')) return - editorRef?.focus() - }} - > -
(scrollRef = el)}> -
{ - editorRef = el - props.ref?.(el) - }} - role="textbox" - aria-multiline="true" - aria-label={designPlaceholder()} - 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, - "min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-base": true, - "[&_[data-type=file]]:text-syntax-property": true, - "[&_[data-type=agent]]:text-syntax-type": true, - "font-mono!": store.mode === "shell", - }} - /> -
- {designPlaceholder()} -
-
-
-
-
- {fileAttachmentInput()} - - - - - - - - - - - -
- - { - const list = e.currentTarget.files - if (list) void addAttachments(Array.from(list)) - e.currentTarget.value = "" - }} - /> - -
- - - -
-
- -
-
0.5 ? "auto" : "none", - }} - > - - - -
-
-
- - - -
-
-
- - {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" - /> - -
-
- - -
-
-
- - - - -
- ) -} - -type ComposerPickerItemState = { - icon: IconProps["name"] - label: string - selected?: boolean - onSelect: () => void -} - -type ComposerPickerTriggerState = { - action: string - icon?: IconProps["name"] - label: string - class?: string - style: JSX.CSSProperties | undefined - onPress: () => void -} - -type ComposerPickerState = { - open: boolean - trigger: ComposerPickerTriggerState - search: string - searchPlaceholder: string - clearLabel: string - items: ComposerPickerItemState[] - action: ComposerPickerItemState - listClass?: string - searchRef: (el: HTMLInputElement) => void - onOpenChange: (open: boolean) => void - onSearchInput: (value: string) => void - onSearchClear: () => void -} - -type ComposerAgentControlState = { - title: string - keybind: string - options: string[] - current: string - style: JSX.CSSProperties | undefined - onSelect: (value: string | undefined) => void -} - -type ComposerModelControlState = { - loading: boolean - paid: boolean - title: string - keybind: string - model: ReturnType["model"] - providerID?: string - modelName: string - style: JSX.CSSProperties | undefined - onClose: () => void - onUnpaidClick: () => void -} - -function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: ComposerPickerTriggerState }) { - const [local, rest] = splitProps(props, ["state", "class", "style", "onClick"]) - return ( - - ) -} - -function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) { - return ( - - ) -} - -function ComposerPicker(props: { state: ComposerPickerState }) { - return ( - - - - event.preventDefault()} - > -
-
- - props.state.onSearchInput(event.currentTarget.value)} - /> - - - -
- {(item) => } -
-
-
- -
- - - - ) -} - -function ComposerAgentControl(props: { state: ComposerAgentControlState }) { - return ( -
-
- -
- - props.onFocus()} - onInput={(event) => props.onInput(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault() - props.onClose() - input?.blur() - return - } - if (!props.open || props.results.length === 0) return - if (event.altKey || event.metaKey) return - if (event.key === "ArrowDown") { - event.preventDefault() - moveActive(1) - return - } - if (event.key === "ArrowUp") { - event.preventDefault() - moveActive(-1) - return - } - if (event.key === "Enter" && !event.isComposing) { - event.preventDefault() - selectActive() - } - }} - /> - - } - aria-label={props.placeholder} - onClick={() => { - props.onClose() - input?.focus() - }} - /> - - -
-
- ) -} - -function HomeSessionSearchResultRow(props: { - record: HomeSessionRecord - selected: boolean - onHighlight: () => void - onSelect: (session: Session) => void -}) { - const globalSync = useServerSync() - const notification = useNotification() - const permission = usePermission() - const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) - const hasPermissions = createMemo( - () => - !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { - return !permission.autoResponds(item, props.record.session.directory) - }), - ) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - return sessionStore.session_working(props.record.session.id) - }) - const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) - const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) - - const key = () => homeSessionSearchKey(props.record) - - return ( -
- } - > -
- - - - - -
- - -
- - 0}> -
- - -
- -
- - {title()} - - - {props.record.projectName} - -
- - ) -} - -function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) { - const language = useLanguage() - return ( -
- - - {(onNewSession) => ( - - {language.t("command.session.new")} - - )} - -
- ) -} - -function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) { - const globalSync = useServerSync() - const notification = useNotification() - const permission = usePermission() - const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false }) - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id)) - const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id)) - const hasPermissions = createMemo( - () => - !!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => { - return !permission.autoResponds(item, props.record.session.directory) - }), - ) - const isWorking = createMemo(() => { - if (hasPermissions()) return false - return sessionStore.session_working(props.record.session.id) - }) - const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent)) - const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0) - - return ( -
- } - > -
- - - - - -
- - -
- - 0}> -
- - -
- - - {title()} - - - - {props.record.projectName} - - - - ) -} - -function HomeSessionSkeleton(props: { label: string }) { - return ( -
-
- -
- - ) -} - -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { - const now = DateTime.local() - const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), - ) - const yesterdaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), - ) - const olderSessions = records.filter((record) => { - const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) - return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") - }) - const olderTitle = - todaySessions.length === 0 && yesterdaySessions.length === 0 - ? language.t("sidebar.project.recentSessions") - : language.t("home.sessions.group.older") - - return [ - { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, - { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, - { id: "older" as const, title: olderTitle, sessions: olderSessions }, - ].filter((group) => group.sessions.length > 0) -} - -function LegacyHome() { - const sync = useServerSync() - const platform = usePlatform() - const dialog = useDialog() - const navigate = useNavigate() - const global = useGlobal() - const server = useServer() - const language = useLanguage() - const homedir = createMemo(() => sync.data.path.home) - const recent = createMemo(() => { - return sync.data.project - .slice() - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5) - }) - - const serverDotClass = createMemo(() => { - const healthy = global.servers.health[server.key]?.healthy - if (healthy === true) return "bg-icon-success-base" - if (healthy === false) return "bg-icon-critical-base" - return "bg-border-weak-base" - }) - - function openProject(server: ServerConnection.Any, directory: string) { - const serverCtx = global.createServerCtx(server) - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - navigate(`/${base64Encode(directory)}`) - } - - async function chooseProject() { - const s = server.current - if (!s) return - - const resolve = (result: string | string[] | null) => { - if (Array.isArray(result)) { - for (const directory of result) { - openProject(s, directory) - } - } else if (result) { - openProject(s, result) - } - } - - if (platform.openDirectoryPickerDialog && server.isLocal()) { - const result = await platform.openDirectoryPickerDialog?.({ - title: language.t("command.project.open"), - multiple: true, - }) - resolve(result) - } else { - dialog.show( - () => , - () => resolve(null), - ) - } - } - - return ( -
- - - - 0}> -
-
-
{language.t("home.recentProjects")}
- -
-
    - - {(project) => ( - - )} - -
-
-
- -
-
{language.t("common.loading")}
- -
-
- -
- -
-
{language.t("home.empty.title")}
-
{language.t("home.empty.description")}
-
- -
-
-
-
- ) -} From 62c0169164130bf6288fddccab2f7b1b104bcc87 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:35:32 +0530 Subject: [PATCH 13/31] =?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 8d485c1c8c1f247947c50963d37b950275cc79ca Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 14:43:45 +0530 Subject: [PATCH 14/31] =?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 | 173 ------------------------------------- 1 file changed, 173 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 88f28c38727c..000000000000 --- a/packages/app/src/index.css +++ /dev/null @@ -1,173 +0,0 @@ -@import "@opencode-ai/ui/styles/tailwind"; -@import "@opencode-ai/ui/v2/styles/tailwind.css"; - -@font-face { - font-family: "JetBrainsMono Nerd Font Mono"; - src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); - font-weight: normal; - font-style: normal; -} - -@font-face { - font-family: "Inter"; - src: url("/assets/Inter.ttf") format("truetype"); - font-weight: 100 900; - 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; - clip-path: inset(0 100% 0 0 round 999px); - will-change: clip-path; - } - - [data-component="getting-started"] { - container-type: inline-size; - container-name: getting-started; - } - - [data-component="dropdown-menu-content"].desktop-app-menu, - [data-component="dropdown-menu-sub-content"].desktop-app-menu { - min-width: 160px; - padding: 2px; - } - - [data-component="dropdown-menu-content"].desktop-app-menu { - width: 160px; - } - - [data-component="dropdown-menu-sub-content"].desktop-app-menu { - width: max-content; - min-width: 240px; - max-width: min(320px, calc(100vw - 24px)); - } - - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-group-label"] { - display: flex; - align-items: center; - height: 28px; - padding: 0 12px; - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-medium); - line-height: 1; - color: var(--text-weak); - } - - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item"], - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"], - [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item"], - [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-sub-trigger"] { - min-height: 28px; - padding: 0 12px; - gap: 8px; - font-weight: var(--font-weight-regular); - line-height: 1; - } - - [data-component="dropdown-menu-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"], - [data-component="dropdown-menu-sub-content"].desktop-app-menu [data-slot="dropdown-menu-item-label"] { - white-space: nowrap; - } - - [data-slot="desktop-app-menu-keybind"] { - margin-left: auto; - color: var(--text-weak); - font-size: var(--font-size-x-small); - font-weight: var(--font-weight-regular); - white-space: nowrap; - } - - [data-slot="desktop-app-menu-chevron"] { - display: flex; - margin-left: auto; - color: var(--icon-base); - } - - [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; - } - } - - [data-slot="titlebar-update-loader"] { - display: block; - flex-shrink: 0; - margin: 1px; - width: 12px; - height: 12px; - border-radius: 9999px; - border: 2px solid color-mix(in srgb, var(--v2-icon-icon-accent) 30%, transparent); - border-top-color: var(--v2-icon-icon-accent); - animation: titlebar-update-loader-spin 0.67s linear infinite; - transform-origin: 50% 50%; - } - - @media (prefers-reduced-motion: reduce) { - [data-slot="titlebar-update-loader"] { - animation: none; - } - } - - @keyframes titlebar-update-loader-spin { - to { - transform: rotate(360deg); - } - } - - @keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } - } -} From 81e9f5dc2fe7ab3a779c1d1faa5b7d136dff2dbb Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Tue, 24 Feb 2026 15:18:55 +0530 Subject: [PATCH 15/31] 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 a2e77cf2361a17d0d9d6c87cf52d3f9873850ba7 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 10:27:26 +0530 Subject: [PATCH 16/31] =?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 | 337 ---------------------- 1 file changed, 337 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 213a48e11f39..000000000000 --- a/packages/ui/src/components/basic-tool.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { createEffect, For, Match, on, onCleanup, onMount, 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 - open?: boolean - onOpenChange?: (open: boolean) => void - 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 } -const deferredMounts: Array<{ active: boolean; fn: () => void }> = [] -let deferredFrame: number | undefined - -function flushDeferredMounts() { - while (deferredMounts.length > 0) { - // Timeline tools are mounted top-to-bottom, but the viewport starts at the latest turn. - // Pop from the end so heavy default-open bodies near the bottom become interactive first. - const item = deferredMounts.pop()! - if (item.active) { - deferredFrame = deferredMounts.length > 0 ? requestAnimationFrame(flushDeferredMounts) : undefined - item.fn() - return - } - } - deferredFrame = undefined -} - -function scheduleDeferredFlush() { - if (deferredFrame !== undefined) return - deferredFrame = requestAnimationFrame(() => { - deferredFrame = requestAnimationFrame(flushDeferredMounts) - }) -} - -function scheduleDeferredMount(fn: () => void) { - const item = { active: true, fn } - deferredMounts.push(item) - scheduleDeferredFlush() - return () => { - item.active = false - } -} - -function scheduleFrameMount(fn: () => void) { - const frame = requestAnimationFrame(fn) - return () => cancelAnimationFrame(frame) -} - -export function BasicTool(props: BasicToolProps) { - const [state, setState] = createStore({ - open: props.defaultOpen ?? false, - ready: !props.defer && (props.defaultOpen ?? false), - }) - const open = () => props.open ?? state.open - const ready = () => state.ready - const pending = () => props.status === "pending" || props.status === "running" - const hasChildren = () => (props.defer ? "children" in props : props.children) - - let cancelReady: (() => void) | undefined - - const cancel = () => { - cancelReady?.() - cancelReady = undefined - } - - const scheduleReady = (initial = false) => { - cancel() - cancelReady = (initial ? scheduleDeferredMount : scheduleFrameMount)(() => { - cancelReady = undefined - if (!open()) return - setState("ready", true) - }) - } - - onCleanup(cancel) - - onMount(() => { - if (props.defer && open()) scheduleReady(true) - }) - - const setOpen = (value: boolean) => { - if (props.open === undefined) setState("open", value) - props.onOpenChange?.(value) - } - - createEffect(() => { - if (!props.forceOpen) return - if (open()) return - setOpen(true) - }) - - createEffect( - on( - open, - (value) => { - if (!props.defer) return - if (!value) { - cancel() - setState("ready", false) - return - } - - scheduleReady() - }, - { 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 - setOpen(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 addef663bf6c7914bea8f7b3362461b862cff163 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 11:18:55 +0530 Subject: [PATCH 17/31] =?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 | 745 -------------------- 2 files changed, 103 insertions(+), 745 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 c57fcd53c96f..000000000000 --- a/packages/opencode/src/session/message-v2.ts +++ /dev/null @@ -1,745 +0,0 @@ -import { EventV2 } from "@opencode-ai/core/event" -import { SessionID, MessageID, PartID } from "./schema" -import { SessionV1 } from "@opencode-ai/core/v1/session" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { - APIError, - AbortedError, - Assistant, - AuthError, - CompactionPart, - ContextOverflowError, - Info, - OutputLengthError, - Part, - StructuredOutputError, - SubtaskPart, - User, - WithParts, - type ToolPart, -} from "@opencode-ai/core/v1/session" - -import { NamedError } from "@opencode-ai/core/util/error" -import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" -import { Database } from "@opencode-ai/core/database/database" -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 "@opencode-ai/core/session/sql" -import { 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 { Effect, Schema } from "effect" -import * as EffectLogger from "@opencode-ai/core/effect/logger" - -/** 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 } - -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 Event = { - Updated: SessionV1.Event.MessageUpdated, - Removed: SessionV1.Event.MessageRemoved, - PartUpdated: SessionV1.Event.PartUpdated, - PartDelta: EventV2.define({ - type: "message.part.delta", - schema: { - sessionID: SessionID, - messageID: MessageID, - partID: PartID, - field: Schema.String, - delta: Schema.String, - }, - }), - PartRemoved: SessionV1.Event.PartRemoved, -} - -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(db: Database.Interface["db"], rows: (typeof MessageTable.$inferSelect)[]) { - const ids = rows.map((row) => row.id) - const partByMessage = new Map() - return Effect.gen(function* () { - if (ids.length > 0) { - const partRows = yield* db - .select() - .from(PartTable) - .where(inArray(PartTable.message_id, ids)) - .orderBy(PartTable.message_id, PartTable.id) - .all() - .pipe(Effect.orDie) - 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/mantle") return true - if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/") - if (model.api.npm === "@ai-sdk/xai") 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 { db } = yield* Database.Service - 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 = yield* db - .select() - .from(MessageTable) - .where(where) - .orderBy(desc(MessageTable.time_created), desc(MessageTable.id)) - .limit(input.limit + 1) - .all() - .pipe(Effect.orDie) - if (rows.length === 0) { - const row = yield* db - .select({ id: SessionTable.id }) - .from(SessionTable) - .where(eq(SessionTable.id, input.sessionID)) - .get() - .pipe(Effect.orDie) - 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 = yield* hydrate(db, 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 - return Effect.gen(function* () { - const result = [] as WithParts[] - let before: string | undefined - while (true) { - const next = yield* 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--) { - const item = next.items[i] - if (item) result.push(item) - } - if (!next.more || !next.cursor) break - before = next.cursor - } - return result - }) -} - -export function parts(messageID: MessageID) { - return Effect.gen(function* () { - const { db } = yield* Database.Service - const rows = yield* db - .select() - .from(PartTable) - .where(eq(PartTable.message_id, messageID)) - .orderBy(PartTable.id) - .all() - .pipe(Effect.orDie) - return rows.map(part) - }) -} - -export const get = Effect.fn("MessageV2.get")(function* (input: { sessionID: SessionID; messageID: MessageID }) { - const { db } = yield* Database.Service - const row = yield* db - .select() - .from(MessageTable) - .where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID))) - .get() - .pipe(Effect.orDie) - if (!row) return yield* new NotFoundError({ message: `Message not found: ${input.messageID}` }) - return { - info: info(row), - parts: yield* 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(yield* stream(sessionID)) -}) - -// filterCompacted reorders messages for model consumption -// ([compaction-user, summary, ...retained tail..., continue-user]), so array -// position is not chronological. Derive each binding by max id (MessageID -// is monotonic via MessageID.ascending) so a pre-compaction overflowing tail -// assistant doesn't get mistaken for the most recent turn. tasks are -// compaction/subtask parts attached to user messages newer than the latest -// finished assistant — i.e. unprocessed work. -export function latest(msgs: WithParts[]) { - let user: User | undefined - let assistant: Assistant | undefined - let finished: Assistant | undefined - for (const msg of msgs) { - const info = msg.info - if (info.role === "user" && (!user || info.id > user.id)) user = info - if (info.role === "assistant" && (!assistant || info.id > assistant.id)) assistant = info - if (info.role === "assistant" && info.finish && (!finished || info.id > finished.id)) finished = info - } - const tasks = msgs.flatMap((m) => - finished && m.info.id <= finished.id - ? [] - : m.parts.filter((p): p is CompactionPart | SubtaskPart => p.type === "compaction" || p.type === "subtask"), - ) - return { user, assistant, finished, tasks } -} - -export function fromError( - e: unknown, - ctx: { providerID: ProviderV2.ID; 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 e instanceof ProviderError.HeaderTimeoutError: - return new APIError( - { - message: e.message, - isRetryable: true, - metadata: { - code: e.name, - timeoutMs: String(e.ms), - }, - }, - { cause: e }, - ).toObject() - case e instanceof ProviderError.ResponseStreamError: - return new APIError( - { - message: e.message, - isRetryable: true, - metadata: { - code: e.name, - }, - }, - { 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 0c9e5410a69b17def783f0bdc962ba9f443215d0 Mon Sep 17 00:00:00 2001 From: Prax Lannister Date: Wed, 25 Feb 2026 14:15:02 +0530 Subject: [PATCH 18/31] =?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 | 1614 ----------------- packages/opencode/src/config/config.ts | 679 ------- packages/opencode/src/session/compaction.ts | 640 ------- packages/ui/src/components/session-turn.tsx | 540 ------ 5 files changed, 33 insertions(+), 3565 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 e4ae4a23e364..000000000000 --- a/packages/app/src/pages/session/message-timeline.tsx +++ /dev/null @@ -1,1614 +0,0 @@ -import { - createEffect, - createMemo, - createSignal, - For, - Index, - on, - onCleanup, - Show, - mapArray, - type Accessor, - type JSX, -} from "solid-js" -import { createStore, produce } from "solid-js/store" -import { Dynamic } from "solid-js/web" -import { useNavigate } from "@solidjs/router" -import { useMutation } from "@tanstack/solid-query" -import { Virtualizer, type VirtualizerHandle } from "virtua/solid" -import { Accordion } from "@opencode-ai/ui/accordion" -import { Button } from "@opencode-ai/ui/button" -import { Card } from "@opencode-ai/ui/card" -import { - ContextToolGroup, - Message, - MessageDivider, - Part as MessagePart, - partDefaultOpen, - type UserActions, -} from "@opencode-ai/ui/message-part" -import { DiffChanges } from "@opencode-ai/ui/diff-changes" -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 { SessionRetry } from "@opencode-ai/ui/session-retry" -import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" -import { TextField } from "@opencode-ai/ui/text-field" -import { TextReveal } from "@opencode-ai/ui/text-reveal" -import { TextShimmer } from "@opencode-ai/ui/text-shimmer" -import type { - AssistantMessage, - Message as MessageType, - Part as PartType, - ToolPart, - UserMessage, -} from "@opencode-ai/sdk/v2" -import { showToast } from "@/utils/toast" -import { Binary } from "@opencode-ai/core/util/binary" -import { getDirectory, getFilename } from "@opencode-ai/core/util/path" -import { Popover as KobaltePopover } from "@kobalte/core/popover" -import { normalize } from "@opencode-ai/ui/session-diff" -import { useFileComponent } from "@opencode-ai/ui/context/file" -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 { useServerSDK } from "@/context/server-sdk" -import { usePlatform } from "@/context/platform" -import { useSettings } from "@/context/settings" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { notifySessionTabsRemoved } from "@/components/titlebar-session-events" -import { messageAgentColor } from "@/utils/agent" -import { sessionTitle } from "@/utils/session-title" -import { makeTimer } from "@solid-primitives/timer" -import { MessageComment, SummaryDiff, Timeline, TimelineRow, TimelineRowMap } from "./message-timeline.data" - -const emptyMessages: MessageType[] = [] -const emptyParts: PartType[] = [] -const emptyTools: ToolPart[] = [] -const emptyAssistantMessages: AssistantMessage[] = [] -const idle = { type: "idle" as const } - -type FramedTimelineRow = Exclude -type TimelineRowByTag = Extract - -function sameKeys(a: readonly string[] | undefined, b: readonly string[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((key, index) => key === b[index]) -} - -const timelineCacheLimit = 16 -const timelineFallbackItemSize = 60 -const timelineCache = new Map() - -function readTimelineCache(id: string, keys: readonly string[]) { - const entry = timelineCache.get(id) - if (!entry) return - if (sameKeys(entry.keys, keys)) return entry.cache - timelineCache.delete(id) -} - -function writeTimelineCache(id: string, keys: readonly string[], handle: VirtualizerHandle | undefined) { - if (!handle || keys.length === 0) return - timelineCache.delete(id) - timelineCache.set(id, { keys: keys.slice(), cache: handle.cache }) - while (timelineCache.size > timelineCacheLimit) timelineCache.delete(timelineCache.keys().next().value!) -} - -function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { - if (!previous?.length) return rows - const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) - return rows.map((row) => { - const existing = byKey.get(TimelineRow.key(row)) - if (!existing) return row - return TimelineRow.equals(existing, row) ? existing : row - }) -} - -const taskDescription = (part: PartType, 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) - } -} - -function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSummaries: boolean }) { - const language = useLanguage() - - return ( -
- - - - -
- ) -} - -function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { - const language = useLanguage() - const maxFiles = 10 - const [state, setState] = createStore({ - showAll: false, - expanded: [] as string[], - }) - const showAll = () => state.showAll - const expanded = () => state.expanded - const overflow = createMemo(() => Math.max(0, props.diffs.length - maxFiles)) - const visible = createMemo(() => (showAll() ? props.diffs : props.diffs.slice(0, maxFiles))) - - return ( -
-
- - {props.diffs.length} {language.t("ui.sessionTurn.diffs.changed")}{" "} - {language.t(props.diffs.length === 1 ? "ui.common.file.one" : "ui.common.file.other")} - - - 0}> - setState("showAll", !showAll())}> - {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")} - - -
-
- setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} - > - - {(diff) => { - const opened = createMemo(() => expanded().includes(diff.file)) - - return ( - - - -
- - - {`\u202A${getDirectory(diff.file)}\u202C`} - - {getFilename(diff.file)} - -
- - - - - - -
-
-
-
- - - - - -
- ) - }} -
-
- 0}> -
setState("showAll", true)}> - {language.t("ui.sessionTurn.diffs.more", { count: String(overflow()) })} -
-
-
-
- ) -} - -function TimelineDiffView(props: { diff: SummaryDiff }) { - const fileComponent = useFileComponent() - const view = normalize(props.diff) - - return ( -
- -
- ) -} - -export function MessageTimeline(props: { - 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 - onHistoryScroll: () => void - onAutoScrollInteraction: (event: MouseEvent) => void - shouldAnchorBottom: () => boolean - centered: boolean - setContentRef: (el: HTMLDivElement) => void - historyShift: boolean - userMessages: UserMessage[] - anchor: (id: string) => string - setRevealMessage?: (fn: (id: string) => void) => void -}) { - let touchGesture: number | undefined - - const navigate = useNavigate() - const serverSDK = useServerSDK() - const sdk = useSDK() - const sync = useSync() - const settings = useSettings() - const dialog = useDialog() - const language = useLanguage() - const { params, sessionKey } = useSessionKey() - const platform = usePlatform() - - let virtualizer: VirtualizerHandle | undefined - const sessionID = createMemo(() => params.id) - const sessionMessages = createMemo(() => { - const id = sessionID() - if (!id) return emptyMessages - return sync.data.message[id] ?? emptyMessages - }) - const messageByID = createMemo(() => new Map(sessionMessages().map((message) => [message.id, message] as const))) - const assistantMessagesByParent = createMemo(() => { - const result = new Map() - for (const message of sessionMessages()) { - if (message.role !== "assistant") continue - const messages = result.get(message.parentID) - if (messages) { - messages.push(message) - continue - } - result.set(message.parentID, [message]) - } - return result - }) - 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 getMsgParts = (msgId: string) => sync.data.part[msgId] ?? emptyParts - const childTaskDescription = createMemo(() => { - const id = sessionID() - if (!id) return - return parentMessages() - .flatMap((message) => getMsgParts(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 messageRowMemos = createMemo( - mapArray( - () => props.userMessages, - (userMessage, indexAccessor) => { - return createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { - const rows = Timeline.constructMessageRows( - userMessage, - getMsgParts, - assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, - indexAccessor(), - settings.general.showReasoningSummaries(), - sessionStatus().type, - activeMessageID() === userMessage.id, - ) - - return reuseTimelineRows(previous, rows) - }) - }, - ), - ) - - const timelineRows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => { - const rows = messageRowMemos().flatMap((memo) => memo()) - if (rows.length === 0) return rows - return reuseTimelineRows(previous, [...rows, new TimelineRow.BottomSpacer()]) - }) - const timelineRowKeys = createMemo(() => timelineRows().map(TimelineRow.key), [] as string[], { equals: sameKeys }) - const virtualCache = createMemo(() => readTimelineCache(sessionKey(), timelineRowKeys())) - const messageRowIndex = createMemo(() => { - const result = new Map() - timelineRows().forEach((row, index) => { - if (!("userMessageID" in row)) return - if (result.has(row.userMessageID)) return - result.set(row.userMessageID, index) - }) - return result - }) - const lastAssistantGroupKey = createMemo(() => { - const result = new Map() - timelineRows().forEach((row) => { - if (row._tag !== "AssistantPart") return - result.set(row.userMessageID, row.group.key) - }) - return result - }) - const keepMounted = createMemo(() => { - const id = activeMessageID() - if (!id) return - const rows = timelineRows() - const index = rows.findLastIndex((row) => "userMessageID" in row && row.userMessageID === id) - if (index < 0) return - return [index] - }) - const activeAssistantMessages = createMemo(() => { - const id = activeMessageID() ?? props.userMessages[props.userMessages.length - 1]?.id - if (!id) return emptyAssistantMessages - return assistantMessagesByParent().get(id) ?? emptyAssistantMessages - }) - const activeAssistantContentVersion = createMemo(() => - activeAssistantMessages() - .flatMap((message) => [ - `${message.id}:${message.time.completed ?? ""}:${message.error?.name ?? ""}`, - ...getMsgParts(message.id).map((part) => { - if (part.type === "text" || part.type === "reasoning") return `${part.id}:${part.type}:${part.text.length}` - if (part.type === "tool") { - const metadata = "metadata" in part.state ? part.state.metadata : undefined - const output = - "output" in part.state && typeof part.state.output === "string" ? part.state.output.length : 0 - const metadataOutput = - metadata && typeof metadata === "object" && "output" in metadata && typeof metadata.output === "string" - ? metadata.output.length - : 0 - return `${part.id}:${part.tool}:${part.state.status}:${output}:${metadataOutput}` - } - return `${part.id}:${part.type}` - }), - ]) - .join("|"), - ) - - createEffect( - on( - () => [timelineRowKeys(), activeAssistantContentVersion(), sessionStatus().type] as const, - () => { - if (!virtualizer) return - if (!props.shouldAnchorBottom() && !measuredBottomAnchored) return - const keys = timelineRowKeys() - if (keys.length === 0) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) - scheduleMeasuredBottomAnchor() - }, - { defer: true }, - ), - ) - - createEffect(() => { - props.setRevealMessage?.((id) => { - const index = messageRowIndex().get(id) - if (index === undefined) return - virtualizer?.scrollToIndex(index, { align: "center" }) - }) - }) - - let cacheSessionKey = sessionKey() - let cacheRowKeys = timelineRowKeys() - let virtualizerSessionKey = cacheSessionKey - let virtualizerRowKeys = cacheRowKeys - let bottomAnchorSessionKey = "" - - const maybeAnchorBottom = () => { - const key = sessionKey() - if (bottomAnchorSessionKey === key) return - if (!virtualizer) return - const keys = timelineRowKeys() - if (keys.length === 0) return - bottomAnchorSessionKey = key - if (!props.shouldAnchorBottom()) return - virtualizer.scrollToIndex(keys.length - 1, { align: "end" }) - } - - createEffect( - on( - () => [sessionKey(), timelineRowKeys()] as const, - (next, prev) => { - if (prev && prev[0] !== next[0]) writeTimelineCache(prev[0], prev[1], virtualizer) - cacheSessionKey = next[0] - cacheRowKeys = next[1] - if (virtualizer) { - virtualizerSessionKey = cacheSessionKey - virtualizerRowKeys = cacheRowKeys - maybeAnchorBottom() - } - }, - { defer: true }, - ), - ) - - onCleanup(() => { - writeTimelineCache(virtualizerSessionKey, virtualizerRowKeys, virtualizer) - props.setRevealMessage?.(() => {}) - }) - - 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), - }) - const [toolOpen, setToolOpen] = createStore>({}) - - let more: HTMLButtonElement | undefined - let head: HTMLDivElement | undefined - let listRoot: HTMLDivElement | undefined - let listFrame: number | undefined - let contentFrame: number | undefined - let bottomAnchorFrame: number | undefined - let bottomAnchorFrames = 0 - let measuredBottomAnchored = true - const [scrollRoot, setScrollRoot] = createSignal() - - const updateTitleMetrics = () => { - if (!head || head.clientWidth <= 0) return - setBar("ms", pace(head.clientWidth)) - } - - createResizeObserver(() => head, updateTitleMetrics) - - const isMeasuredBottom = (root: HTMLDivElement) => root.scrollHeight - root.clientHeight - root.scrollTop <= 4 - - const measureTimeline = () => { - virtualizer?.measure() - anchorMeasuredBottom() - } - - function anchorMeasuredBottom() { - if (!listRoot) return false - if (!measuredBottomAnchored) return false - listRoot.scrollTop = listRoot.scrollHeight - return true - } - - function scheduleMeasuredBottomAnchor() { - // Workaround for virtua issue #301: virtua does not expose a synchronous item-resize hook for - // "stay at bottom if already at bottom". Tool rows can briefly outgrow the measured virtual - // height, so keep the scroll container bottom-locked for a few frames while measurement settles. - bottomAnchorFrames = 90 - if (bottomAnchorFrame !== undefined) return - - const tick = () => { - bottomAnchorFrame = undefined - if (!anchorMeasuredBottom()) { - bottomAnchorFrames = 0 - return - } - - bottomAnchorFrames = working() ? 12 : bottomAnchorFrames - 1 - if (bottomAnchorFrames <= 0) return - bottomAnchorFrame = requestAnimationFrame(tick) - } - - bottomAnchorFrame = requestAnimationFrame(tick) - } - - const bindContentRoot = (root: HTMLDivElement) => { - const child = root.firstElementChild - props.setContentRef(child instanceof HTMLDivElement ? child : root) - } - - const scheduleContentRoot = (root: HTMLDivElement) => { - if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) - contentFrame = requestAnimationFrame(() => { - contentFrame = undefined - if (listRoot !== root) return - bindContentRoot(root) - }) - } - - const connectListRoot = (root: HTMLDivElement) => { - if (listRoot !== root) return - if (!root.isConnected || !root.ownerDocument.defaultView) { - listFrame = requestAnimationFrame(() => { - listFrame = undefined - connectListRoot(root) - }) - return - } - - props.setScrollRef(root) - measuredBottomAnchored = isMeasuredBottom(root) - setScrollRoot(root) - scheduleContentRoot(root) - } - - const bindListRoot = (root: HTMLDivElement) => { - if (root === listRoot) return - - if (listFrame !== undefined) cancelAnimationFrame(listFrame) - if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) - listRoot = root - setScrollRoot(undefined) - connectListRoot(root) - } - - const handleListWheel = (event: WheelEvent & { currentTarget: HTMLDivElement }) => { - const root = event.currentTarget - const delta = normalizeWheelDelta({ - deltaY: event.deltaY, - deltaMode: event.deltaMode, - rootHeight: root.clientHeight, - }) - if (!delta) return - markBoundaryGesture({ root, target: event.target, delta, onMarkScrollGesture: props.onMarkScrollGesture }) - } - - const handleListTouchStart = (event: TouchEvent) => { - touchGesture = event.touches[0]?.clientY - } - - const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => { - const next = event.touches[0]?.clientY - const prev = touchGesture - touchGesture = next - if (next === undefined || prev === undefined) return - - const delta = prev - next - if (!delta) return - - markBoundaryGesture({ - root: event.currentTarget, - target: event.target, - delta, - onMarkScrollGesture: props.onMarkScrollGesture, - }) - } - - const handleListTouchEnd = () => { - touchGesture = undefined - } - - const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => { - if (event.target !== event.currentTarget) return - props.onMarkScrollGesture(event.currentTarget) - } - - const handleListScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - measuredBottomAnchored = isMeasuredBottom(event.currentTarget) - props.onScheduleScrollState(event.currentTarget) - props.onHistoryScroll() - if (!props.hasScrollGesture()) return - props.onUserScroll() - props.onAutoScrollHandleScroll() - props.onMarkScrollGesture(event.currentTarget) - } - - onCleanup(() => { - if (listFrame !== undefined) cancelAnimationFrame(listFrame) - if (contentFrame !== undefined) cancelAnimationFrame(contentFrame) - if (bottomAnchorFrame !== undefined) cancelAnimationFrame(bottomAnchorFrame) - setScrollRoot(undefined) - props.setScrollRef(undefined) - }) - - 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) => serverSDK.client.session.share({ sessionID: id, directory: sdk.directory }), - onError: (err) => { - console.error("Failed to share session", err) - }, - })) - - const unshareMutation = useMutation(() => ({ - mutationFn: (id: string) => serverSDK.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) - }), - ) - sync.session.evict(sessionID) - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [sessionID] }) - }) - .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 - - const removed = new Set([sessionID]) - const byParent = new Map() - for (const item of sync.data.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) - } - } - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - - sync.set( - produce((draft) => { - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - for (const id of removed) { - sync.session.evict(id) - } - notifySessionTabsRemoved({ directory: sdk.directory, sessionIDs: [...removed] }) - 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() })} - -
-
- - -
-
-
- ) - } - - const workingTurn = (userMessageID: string) => sessionStatus().type !== "idle" && activeMessageID() === userMessageID - - const turnDurationMs = (userMessageID: string) => { - const message = messageByID().get(userMessageID) - if (!message || message.role !== "user") return - const end = (assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages).reduce( - (max, item) => { - const completed = item.time.completed - if (typeof completed !== "number") return max - if (max === undefined) return completed - return Math.max(max, completed) - }, - undefined, - ) - if (typeof end !== "number") return - if (end < message.time.created) return - return end - message.time.created - } - - const assistantCopyPartID = (userMessageID: string) => { - if (workingTurn(userMessageID)) return null - const messages = assistantMessagesByParent().get(userMessageID) ?? emptyAssistantMessages - - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (!message) continue - - const parts = getMsgParts(message.id) - for (let j = parts.length - 1; j >= 0; j--) { - const part = parts[j] - if (!part || part.type !== "text" || !part.text?.trim()) continue - return part.id - } - } - } - - const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID) - - const renderAssistantPartGroup = (row: Accessor) => { - if (row().group.type === "context") { - const parts = createMemo(() => { - const group = row().group - if (group.type !== "context") return emptyTools - return group.refs - .map((ref) => getMsgPart(ref.messageID, ref.partID)) - .filter((part): part is ToolPart => part?.type === "tool") - }) - - return ( - - ) - } - - const message = createMemo(() => { - const group = row().group - if (group.type !== "part") return - return messageByID().get(group.ref.messageID) - }) - const part = createMemo(() => { - const group = row().group - if (group.type !== "part") return - return getMsgPart(group.ref.messageID, group.ref.partID) - }) - const defaultOpen = createMemo(() => { - const item = part() - if (!item) return - return partDefaultOpen(item, settings.general.shellToolPartsExpanded(), settings.general.editToolPartsExpanded()) - }) - - return ( - - {(message) => ( - - {(part) => ( - setToolOpen(part().id, open)} - deferToolContent={false} - virtualizeDiff={false} - /> - )} - - )} - - ) - } - - function TimelineRowFrame(input: { row: Accessor; children: JSX.Element }) { - const anchor = () => { - const row = input.row() - return row._tag === "CommentStrip" || (row._tag === "UserMessage" && row.anchor) - } - const previousUserMessage = () => { - const row = input.row() - return (row._tag === "CommentStrip" || row._tag === "UserMessage") && row.previousUserMessage - } - const previousAssistantPart = () => { - const row = input.row() - return row._tag === "AssistantPart" && row.previousAssistantPart - } - - return ( -
-
- {input.children} -
-
- ) - } - - const renderTimelineRow = (row: Accessor) => { - switch (row()._tag) { - case "CommentStrip": { - const commentStripRow = row as Accessor> - const comments = createMemo(() => - getMsgParts(commentStripRow().userMessageID).flatMap((part) => MessageComment.fromPart(part) ?? []), - ) - return ( - -
-
-
- - {(comment) => ( -
-
- - {getFilename(comment().path)} - - {(selection) => ( - - {selection().startLine === selection().endLine - ? `:${selection().startLine}` - : `:${selection().startLine}-${selection().endLine}`} - - )} - -
-
- {comment().comment} -
-
- )} -
-
-
-
-
- ) - } - case "UserMessage": { - const userMessageRow = row as Accessor> - const message = createMemo(() => { - const m = messageByID().get(userMessageRow().userMessageID) - if (m?.role === "user") return m - }) - return ( - - - {(message) => ( -
-
- -
-
- )} -
-
- ) - } - case "TurnDivider": { - const turnDividerRow = row as Accessor> - return ( - -
-
- -
-
-
- ) - } - case "AssistantPart": { - const assistantPartRow = row as Accessor> - return ( - -
-
- {renderAssistantPartGroup(assistantPartRow)} -
-
-
- ) - } - case "Thinking": { - const thinkingRow = row as Accessor> - return ( - -
- -
-
- ) - } - case "Retry": { - const retryRow = row as Accessor> - return ( - -
- -
-
- ) - } - case "DiffSummary": { - const diffSummaryRow = row as Accessor> - return ( - -
- -
-
- ) - } - case "Error": { - const errorRow = row as Accessor> - return ( - -
- - {errorRow().text} - -
-
- ) - } - case "BottomSpacer": - return