Compare commits

...
Author SHA1 Message Date
James Long a73ac650e9 fix(tui): revert "fix(core): handle SIGHUP and kill process (#16057)"
This reverts commit 45ac20b8aa.
2026-03-09 11:56:47 -04:00
James Long 366b8a8034 feat(tui): add initial support for workspaces into the tui (#16230) 2026-03-09 10:28:04 -04:00
Armin PašalićandAiden Cline ef9bc4ec9e feat(gitlab): send context-1m-2025-08-07 beta header to enable 1M context window (#16153)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-09 09:22:00 -05:00
Jack 5838b58913 add copilot gpt-5.4 xhigh support (#16294) 2026-03-09 22:07:12 +08:00
opencode 2712244ad3 release: v1.2.23 2026-03-09 13:50:43 +00:00
Adam 6388cbaf92 fix(app): remove oc-1 theme 2026-03-09 08:25:41 -05:00
David Hill 5cc61e1b53 tui: fix sidebar workspace container sizing by adding box-border class to prevent content overflow issues 2026-03-09 13:05:43 +00:00
Adam 0243be86a7 fix(app): don't animate review panel in/out 2026-03-09 07:49:11 -05:00
opencode-agent[bot] 9154cd64e7 chore: update nix node_modules hashes 2026-03-09 12:46:47 +00:00
Adam c71d1bde5e revert(app): "STUPID SEXY TIMELINE (#16420)" (#16745) 2026-03-09 07:36:39 -05:00
Luke Parker f27ef595f6 fix(app): sanitize workspace store filenames on Windows (#16703) 2026-03-09 20:26:53 +10:00
Yihui Khuu 34328828ae fix(app): fix issue with scroll jumping when pressing escape in comment text area (#15374) 2026-03-09 15:29:24 +05:30
18fb19da3b fix(opencode): pass missing auth headers in run --attach (#16097)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Shoubhit Dash <shoubhit2005@gmail.com>
2026-03-09 07:32:13 +00:00
opencode-agent[bot] 849e1ac543 docs(i18n): sync locale docs from english changes 2026-03-09 02:08:46 +00:00
Ariane Emory 656a8d8f55 docs: add session_child_first keybinding to documentation (#16631) 2026-03-08 21:03:52 -05:00
Adam b976f339e8 feat(app): generate color palettes (#16232) 2026-03-08 19:28:58 -05:00
Dax Raad 7d7837e5b6 disable fallback to free nano for small model 2026-03-08 19:27:15 -04:00
137 changed files with 4492 additions and 8651 deletions
+16 -23
View File
@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -76,7 +76,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -110,7 +110,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -137,7 +137,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -161,7 +161,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -185,7 +185,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -218,7 +218,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -248,7 +248,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -277,7 +277,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -293,7 +293,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.22",
"version": "1.2.23",
"bin": {
"opencode": "./bin/opencode",
},
@@ -409,7 +409,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -429,7 +429,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.22",
"version": "1.2.23",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -440,7 +440,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -475,7 +475,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -483,11 +483,8 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/lifecycle": "0.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/page-visibility": "2.1.1",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/rootless": "1.5.2",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"dompurify": "3.3.1",
@@ -524,7 +521,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"zod": "catalog:",
},
@@ -535,7 +532,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1837,14 +1834,10 @@
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
"@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
"@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-c99eE1cKAQHvwJosaFo42U9Hk0Rtp/U5oTTlyiz2Zw4=",
"aarch64-linux": "sha256-LbdssPrf8Bijyp4mRo8QaO/swxwUWSo1g0jLPm2rvUA=",
"aarch64-darwin": "sha256-0L9y6Zk4l2vAxsM2bENahhtRZY1C3XhdxLgnnYlhkkY=",
"x86_64-darwin": "sha256-0J5sFG/kHHRDcTpdpdPBMJEOHwCRnAUYmbxEHPPLDvU="
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
}
}
+2 -5
View File
@@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
import {
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
sessionTimelineHeaderSelector,
projectMenuTriggerSelector,
projectCloseMenuSelector,
projectWorkspacesToggleSelector,
@@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
const scroller = page.locator(".scroll-view__viewport").first()
await expect(scroller).toBeVisible()
const header = page.locator(sessionTimelineHeaderSelector).first()
await expect(header).toBeVisible({ timeout: 30_000 })
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
@@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
if (opened) return menu
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
@@ -25,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
})
.poll(
() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
},
{ timeout: 15_000 },
)
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect(otherButton).toHaveCount(0)
await expect
.poll(
async () => {
return await page.locator(projectSwitchSelector(otherSlug)).count()
},
{ timeout: 15_000 },
)
.toBe(0)
},
{ extra: [other] },
)
-2
View File
@@ -51,8 +51,6 @@ export const dropdownMenuContentSelector = '[data-component="dropdown-menu-conte
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionTimelineHeaderSelector = "[data-session-title]"
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
+4 -8
View File
@@ -7,7 +7,7 @@ import {
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector, sessionTimelineHeaderSelector } from "../selectors"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
@@ -39,14 +39,12 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
originalTitle,
)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(sessionTimelineHeaderSelector).locator(inlineInputSelector).first()
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await expect(input).toBeFocused()
await input.fill(renamedTitle)
@@ -63,9 +61,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
)
.toBe(renamedTitle)
await expect(page.locator(sessionTimelineHeaderSelector).getByRole("heading", { level: 1 }).first()).toHaveText(
renamedTitle,
)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
+47 -4
View File
@@ -83,16 +83,23 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
const currentThemeId = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
const currentTheme = (await select.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
expect(firstTheme).toBeTruthy()
const nextTheme = (await items.locator('[data-slot="select-select-item-label"]').allTextContents())
.map((x) => x.trim())
.find((x) => x && x !== currentTheme)
expect(nextTheme).toBeTruthy()
await items.nth(1).click()
await items.filter({ hasText: nextTheme! }).first().click()
await page.keyboard.press("Escape")
@@ -101,7 +108,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
})
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe("oc-1")
expect(storedThemeId).not.toBe(currentThemeId)
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
@@ -109,6 +116,42 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) =>
expect(dataTheme).toBe(storedThemeId)
})
test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
})
await gotoSession()
await expect(page.locator("html")).toHaveAttribute("data-theme", "oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
})
.toBe("oc-2")
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-light")
})
})
.toBeNull()
await expect
.poll(async () => {
return await page.evaluate(() => {
return localStorage.getItem("opencode-theme-css-dark")
})
})
.toBeNull()
})
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
+1 -1
View File
@@ -57,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
}
export function workspacePersistKey(directory: string, key: string) {
const head = directory.slice(0, 12) || "workspace"
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(directory) ?? "0"
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.22",
"version": "1.2.23",
"description": "",
"type": "module",
"exports": {
+11 -4
View File
@@ -1,6 +1,13 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var key = "opencode-theme-id"
var themeId = localStorage.getItem(key) || "oc-2"
if (themeId === "oc-1") {
themeId = "oc-2"
localStorage.setItem(key, themeId)
localStorage.removeItem("opencode-theme-css-light")
localStorage.removeItem("opencode-theme-css-dark")
}
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
@@ -9,9 +16,9 @@
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
if (themeId === "oc-2") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
var css = localStorage.getItem("opencode-theme-css-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
+1 -1
View File
@@ -217,7 +217,7 @@ export const Terminal = (props: TerminalProps) => {
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
+1 -1
View File
@@ -1917,7 +1917,7 @@ export default function Layout(props: ParentProps) {
return (
<div
classList={{
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
"border border-b-0 border-border-weak-base": !merged(),
"border-l border-t border-border-weaker-base": merged(),
"bg-background-base": merged() || hover(),
+235 -53
View File
@@ -41,12 +41,220 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
const emptyUserMessages: UserMessage[] = []
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
userScrolled: () => boolean
scroller: () => HTMLDivElement | undefined
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const preserveScroll = (fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
fn()
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (!delta) return
el.scrollTop = beforeTop + delta
})
}
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserveScroll(() => setTurnStart(nextStart))
}
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
if (start > 0) setTurnStart(0)
if (!input.historyMore() || input.historyLoading()) return
await input.loadMore(id)
if (input.sessionID() !== id) return
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
if (growth <= 0) return
if (turnStart() !== 0) return
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
await input.loadMore(id)
if (input.sessionID() !== id) return
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (opts?.prefetch) {
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (growth > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (growth <= 0) return
if (turnStart() !== start) return
const reveal = !opts?.prefetch
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
const nextStart = Math.max(0, afterVisible - target)
preserveScroll(() => setTurnStart(nextStart))
}
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
void fetchOlderMessages()
}
createEffect(
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
return {
turnStart,
setTurnStart,
renderedUserMessages,
loadAndReveal,
onScrollerScroll,
}
}
export default function Page() {
const globalSync = useGlobalSync()
const layout = useLayout()
@@ -77,6 +285,7 @@ export default function Page() {
const [ui, setUi] = createStore({
git: false,
pendingMessage: undefined as string | undefined,
reviewSnap: false,
scrollGesture: 0,
scroll: {
overflow: false,
@@ -252,6 +461,21 @@ export default function Page() {
return key
}, sessionKey())
let reviewFrame: number | undefined
createComputed((prev) => {
const open = desktopReviewOpen()
if (prev === undefined || prev === open) return open
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
setUi("reviewSnap", true)
reviewFrame = requestAnimationFrame(() => {
reviewFrame = undefined
setUi("reviewSnap", false)
})
return open
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const reviewDiffs = createMemo(() => (store.changes === "session" ? diffs() : turnDiffs()))
@@ -886,7 +1110,6 @@ export default function Page() {
let scrollStateFrame: number | undefined
let scrollStateTarget: HTMLDivElement | undefined
let historyFillFrame: number | undefined
const scrollSpy = createScrollSpy({
onActive: (id) => {
if (id === store.messageId) return
@@ -897,7 +1120,7 @@ export default function Page() {
const updateScrollState = (el: HTMLDivElement) => {
const max = el.scrollHeight - el.clientHeight
const overflow = max > 1
const bottom = !overflow || Math.abs(el.scrollTop) <= 2 || !autoScroll.userScrolled()
const bottom = !overflow || el.scrollTop >= max - 2
if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return
setUi("scroll", { overflow, bottom })
@@ -920,7 +1143,7 @@ export default function Page() {
const resumeScroll = () => {
setStore("messageId", undefined)
autoScroll.smoothScrollToBottom()
autoScroll.forceScrollToBottom()
clearMessageHash()
const el = scroller
@@ -956,9 +1179,7 @@ export default function Page() {
scroller = el
autoScroll.scrollRef(el)
scrollSpy.setContainer(el)
if (!el) return
scheduleScrollState(el)
scheduleHistoryFill()
if (el) scheduleScrollState(el)
}
createResizeObserver(
@@ -967,7 +1188,6 @@ export default function Page() {
const el = scroller
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
scheduleHistoryFill()
},
)
@@ -982,45 +1202,6 @@ export default function Page() {
scroller: () => scroller,
})
const scheduleHistoryFill = () => {
if (historyFillFrame !== undefined) return
historyFillFrame = requestAnimationFrame(() => {
historyFillFrame = undefined
if (!params.id || !messagesReady()) return
if (autoScroll.userScrolled() || historyLoading()) return
const el = scroller
if (!el) return
if (el.scrollHeight > el.clientHeight + 1) return
if (historyWindow.turnStart() <= 0 && !historyMore()) return
void historyWindow.loadAndReveal()
})
}
createEffect(
on(
() =>
[
params.id,
messagesReady(),
historyWindow.turnStart(),
historyMore(),
historyLoading(),
autoScroll.userScrolled(),
visibleUserMessages().length,
] as const,
([id, ready, start, more, loading, scrolled]) => {
if (!id || !ready || loading || scrolled) return
if (start <= 0 && !more) return
scheduleHistoryFill()
},
{ defer: true },
),
)
createResizeObserver(
() => promptDock,
({ height }) => {
@@ -1030,15 +1211,16 @@ export default function Page() {
const el = scroller
const delta = next - dockHeight
const stick = el ? Math.abs(el.scrollTop) < 10 + Math.max(0, delta) : false
const stick = el
? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
: false
dockHeight = next
if (stick) autoScroll.smoothScrollToBottom()
if (stick) autoScroll.forceScrollToBottom()
if (el) scheduleScrollState(el)
scrollSpy.markDirty()
scheduleHistoryFill()
},
)
@@ -1067,8 +1249,8 @@ export default function Page() {
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
scrollSpy.destroy()
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
})
return (
@@ -1089,7 +1271,7 @@ export default function Page() {
classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!size.active(),
!size.active() && !ui.reviewSnap,
}}
style={{
width: sessionPanelWidth(),
@@ -1122,7 +1304,6 @@ export default function Page() {
onScrollSpyScroll={scrollSpy.onScroll}
onTurnBackfillScroll={historyWindow.onScrollerScroll}
onAutoScrollInteraction={autoScroll.handleInteraction}
onPreserveScrollAnchor={autoScroll.preserve}
centered={centered()}
setContentRef={(el) => {
content = el
@@ -1205,6 +1386,7 @@ export default function Page() {
reviewPanel={reviewPanel}
activeDiff={tree.activeDiff}
focusReviewDiff={focusReviewDiff}
reviewSnap={ui.reviewSnap}
size={size}
/>
</div>
@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={props.state.questionRequest()} keyed>
+2 -2
View File
@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
)
return (
<Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full min-h-0 flex-1"
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
@@ -1,35 +0,0 @@
import { describe, expect, test } from "bun:test"
import { historyLoadMode, historyRevealTop } from "./history-window"
describe("historyLoadMode", () => {
test("reveals cached turns before fetching", () => {
expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
})
test("fetches older history when cache is already revealed", () => {
expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
})
test("does nothing while history is unavailable or loading", () => {
expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
})
})
describe("historyRevealTop", () => {
test("pins the viewport to the top when older turns were revealed there", () => {
expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
-1400,
)
})
test("keeps the latest turns pinned when the viewport was underfilled", () => {
expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
})
test("keeps the current anchor when the user was not at the top", () => {
expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
-200,
)
})
})
@@ -1,273 +0,0 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, on } from "solid-js"
import { createStore } from "solid-js/store"
import { same } from "@/utils/same"
export const emptyUserMessages: UserMessage[] = []
export type SessionHistoryWindowInput = {
sessionID: () => string | undefined
messagesReady: () => boolean
visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
userScrolled: () => boolean
scroller: () => HTMLDivElement | undefined
}
type Snap = {
top: number
height: number
gap: number
max: number
}
export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
if (input.start > 0) return "reveal"
if (!input.more || input.loading) return "noop"
return "fetch"
}
export const historyRevealTop = (
mark: { top: number; height: number; gap: number; max: number },
next: { clientHeight: number; height: number },
threshold = 16,
) => {
const delta = next.height - mark.height
if (delta <= 0) return mark.top
if (mark.max <= 0) return mark.top
if (mark.gap > threshold) return mark.top
const max = next.height - next.clientHeight
if (max <= 0) return 0
return Math.max(-max, Math.min(0, mark.top - delta))
}
const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
if (!el) return
const max = el.scrollHeight - el.clientHeight
return {
top: el.scrollTop,
height: el.scrollHeight,
gap: max + el.scrollTop,
max,
}
}
const clamp = (el: HTMLDivElement, top: number) => {
const max = el.scrollHeight - el.clientHeight
if (max <= 0) return 0
return Math.max(-max, Math.min(0, top))
}
const revealThreshold = 16
const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
const el = input.scroller()
if (!el || !mark) return
el.scrollTop = clamp(
el,
historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
)
}
const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
const el = input.scroller()
if (!el) {
fn()
return
}
const top = el.scrollTop
fn()
el.scrollTop = top
}
/**
* Maintains the rendered history window for a session timeline.
*
* It keeps initial paint bounded to recent turns, reveals cached turns in
* small batches while scrolling upward, and prefetches older history near top.
*/
export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
const turnInit = 10
const turnBatch = 8
const turnScrollThreshold = 200
const turnPrefetchBuffer = 16
const prefetchCooldownMs = 400
const prefetchNoGrowthLimit = 2
const [state, setState] = createStore({
turnID: undefined as string | undefined,
turnStart: 0,
prefetchUntil: 0,
prefetchNoGrowth: 0,
})
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
const turnStart = createMemo(() => {
const id = input.sessionID()
const len = input.visibleUserMessages().length
if (!id || len <= 0) return 0
if (state.turnID !== id) return initialTurnStart(len)
if (state.turnStart <= 0) return 0
if (state.turnStart >= len) return initialTurnStart(len)
return state.turnStart
})
const setTurnStart = (start: number) => {
const id = input.sessionID()
const next = start > 0 ? start : 0
if (!id) {
setState({ turnID: undefined, turnStart: next })
return
}
setState({ turnID: id, turnStart: next })
}
const renderedUserMessages = createMemo(
() => {
const msgs = input.visibleUserMessages()
const start = turnStart()
if (start <= 0) return msgs
return msgs.slice(start)
},
emptyUserMessages,
{
equals: same,
},
)
const backfillTurns = () => {
const start = turnStart()
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
preserve(input, () => setTurnStart(nextStart))
}
/** Button path: reveal cached turns first, then fetch older history. */
const loadAndReveal = async () => {
const id = input.sessionID()
if (!id) return
const start = turnStart()
const mode = historyLoadMode({
start,
more: input.historyMore(),
loading: input.historyLoading(),
})
if (mode === "reveal") {
const mark = snap(input.scroller())
setTurnStart(0)
reveal(input, mark)
return
}
if (mode === "noop") return
const beforeVisible = input.visibleUserMessages().length
const mark = snap(input.scroller())
await input.loadMore(id)
if (input.sessionID() !== id) return
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (growth <= 0) return
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
reveal(input, mark)
}
/** Scroll/prefetch path: fetch older history from server. */
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
const id = input.sessionID()
if (!id) return
if (!input.historyMore() || input.historyLoading()) return
if (opts?.prefetch) {
const now = Date.now()
if (state.prefetchUntil > now) return
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
setState("prefetchUntil", now + prefetchCooldownMs)
}
const start = turnStart()
const beforeVisible = input.visibleUserMessages().length
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
await input.loadMore(id)
if (input.sessionID() !== id) return
const afterVisible = input.visibleUserMessages().length
const growth = afterVisible - beforeVisible
if (opts?.prefetch) {
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
} else if (growth > 0 && state.prefetchNoGrowth) {
setState("prefetchNoGrowth", 0)
}
if (growth <= 0) return
if (turnStart() !== start) return
const revealMore = !opts?.prefetch
const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered)
const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
const nextStart = Math.max(0, afterVisible - target)
preserve(input, () => setTurnStart(nextStart))
}
const onScrollerScroll = () => {
if (!input.userScrolled()) return
const el = input.scroller()
if (!el) return
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
const start = turnStart()
if (start > 0) {
if (start <= turnPrefetchBuffer) {
void fetchOlderMessages({ prefetch: true })
}
backfillTurns()
return
}
void fetchOlderMessages()
}
createEffect(
on(
input.sessionID,
() => {
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
},
{ defer: true },
),
)
createEffect(
on(
() => [input.sessionID(), input.messagesReady()] as const,
([id, ready]) => {
if (!id || !ready) return
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
},
{ defer: true },
),
)
return {
turnStart,
setTurnStart,
renderedUserMessages,
loadAndReveal,
onScrollerScroll,
}
}
@@ -1,31 +1,27 @@
import {
For,
Index,
createEffect,
createMemo,
createSignal,
on,
onCleanup,
Show,
startTransition,
type JSX,
} from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { Binary } from "@opencode-ai/util/binary"
import { getFilename } from "@opencode-ai/util/path"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { SessionTimelineHeader } from "@/pages/session/session-timeline-header"
type MessageComment = {
path: string
@@ -37,9 +33,7 @@ type MessageComment = {
}
const emptyMessages: MessageType[] = []
const isDefaultSessionTitle = (title?: string) =>
!!title && /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
const idle = { type: "idle" as const }
const messageComments = (parts: Part[]): MessageComment[] =>
parts.flatMap((part) => {
@@ -116,8 +110,6 @@ function createTimelineStaging(input: TimelineStageInput) {
completedSession: "",
count: 0,
})
const [readySession, setReadySession] = createSignal("")
let active = ""
const stagedCount = createMemo(() => {
const total = input.messages().length
@@ -142,46 +134,23 @@ function createTimelineStaging(input: TimelineStageInput) {
cancelAnimationFrame(frame)
frame = undefined
}
const scheduleReady = (sessionKey: string) => {
if (input.sessionKey() !== sessionKey) return
if (readySession() === sessionKey) return
setReadySession(sessionKey)
}
createEffect(
on(
() => [input.sessionKey(), input.turnStart() > 0, input.messages().length] as const,
([sessionKey, isWindowed, total]) => {
const switched = active !== sessionKey
if (switched) {
active = sessionKey
setReadySession("")
}
const staging = state.activeSession === sessionKey && state.completedSession !== sessionKey
const shouldStage = isWindowed && total > input.config.init && state.completedSession !== sessionKey
if (staging && !switched && shouldStage && frame !== undefined) return
cancel()
if (shouldStage) setReadySession("")
const shouldStage =
isWindowed &&
total > input.config.init &&
state.completedSession !== sessionKey &&
state.activeSession !== sessionKey
if (!shouldStage) {
setState({
activeSession: "",
completedSession: isWindowed ? sessionKey : state.completedSession,
count: total,
})
if (total <= 0) {
setReadySession("")
return
}
if (readySession() !== sessionKey) scheduleReady(sessionKey)
setState({ activeSession: "", count: total })
return
}
let count = Math.min(total, input.config.init)
if (staging) count = Math.min(total, Math.max(count, state.count))
setState({ activeSession: sessionKey, count })
const step = () => {
@@ -191,11 +160,10 @@ function createTimelineStaging(input: TimelineStageInput) {
}
const currentTotal = input.messages().length
count = Math.min(currentTotal, count + input.config.batch)
startTransition(() => setState("count", count))
setState("count", count)
if (count >= currentTotal) {
setState({ completedSession: sessionKey, activeSession: "" })
frame = undefined
scheduleReady(sessionKey)
return
}
frame = requestAnimationFrame(step)
@@ -209,12 +177,9 @@ function createTimelineStaging(input: TimelineStageInput) {
const key = input.sessionKey()
return state.activeSession === key && state.completedSession !== key
})
const ready = createMemo(() => readySession() === input.sessionKey())
onCleanup(() => {
cancel()
})
return { messages: stagedUserMessages, isStaging, ready }
onCleanup(cancel)
return { messages: stagedUserMessages, isStaging }
}
export function MessageTimeline(props: {
@@ -231,7 +196,6 @@ export function MessageTimeline(props: {
onScrollSpyScroll: () => void
onTurnBackfillScroll: () => void
onAutoScrollInteraction: (event: MouseEvent) => void
onPreserveScrollAnchor: (target: HTMLElement) => void
centered: boolean
setContentRef: (el: HTMLDivElement) => void
turnStart: number
@@ -246,19 +210,14 @@ export function MessageTimeline(props: {
let touchGesture: number | undefined
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const dialog = useDialog()
const language = useLanguage()
const trigger = (target: EventTarget | null) => {
const next =
target instanceof Element
? target.closest('[data-slot="collapsible-trigger"], [data-slot="accordion-trigger"], [data-scroll-preserve]')
: undefined
if (!(next instanceof HTMLElement)) return
return next
}
const rendered = createMemo(() => props.renderedUserMessages.map((message) => message.id))
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const sessionID = createMemo(() => params.id)
const sessionMessages = createMemo(() => {
@@ -271,20 +230,28 @@ export function MessageTimeline(props: {
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
),
)
const sessionStatus = createMemo(() => sync.data.session_status[sessionID() ?? ""]?.type ?? "idle")
const sessionStatus = createMemo(() => {
const id = sessionID()
if (!id) return idle
return sync.data.session_status[id] ?? idle
})
const activeMessageID = createMemo(() => {
const messages = sessionMessages()
const message = pending()
if (message?.parentID) {
const result = Binary.search(messages, message.parentID, (item) => item.id)
const parent = result.found ? messages[result.index] : messages.find((item) => item.id === message.parentID)
if (parent?.role === "user") return parent.id
const 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
}
if (sessionStatus() === "idle") return undefined
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === "user") return messages[i].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(() => {
@@ -292,19 +259,9 @@ export function MessageTimeline(props: {
if (!id) return
return sync.session.get(id)
})
const titleValue = createMemo(() => {
const title = info()?.title
if (!title) return
if (isDefaultSessionTitle(title)) return language.t("command.session.new")
return title
})
const defaultTitle = createMemo(() => isDefaultSessionTitle(info()?.title))
const headerTitle = createMemo(
() => titleValue() ?? (props.renderedUserMessages.length ? language.t("command.session.new") : undefined),
)
const placeholderTitle = createMemo(() => defaultTitle() || (!info()?.title && props.renderedUserMessages.length > 0))
const titleValue = createMemo(() => info()?.title)
const parentID = createMemo(() => info()?.parentID)
const showHeader = createMemo(() => !!(headerTitle() || parentID()))
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
sessionKey,
@@ -312,7 +269,212 @@ export function MessageTimeline(props: {
messages: () => props.renderedUserMessages,
config: stageCfg,
})
const rendered = createMemo(() => staging.messages().map((message) => message.id))
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!sessionID()) return
setTitle({ editing: true, draft: titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show
@@ -336,18 +498,7 @@ export function MessageTimeline(props: {
<Icon name="arrow-down-to-line" />
</button>
</div>
<SessionTimelineHeader
centered={props.centered}
showHeader={showHeader}
sessionKey={sessionKey}
sessionID={sessionID}
parentID={parentID}
titleValue={titleValue}
headerTitle={headerTitle}
placeholderTitle={placeholderTitle}
/>
<ScrollView
reverse
viewportRef={props.setScrollRef}
onWheel={(e) => {
const root = e.currentTarget
@@ -381,18 +532,9 @@ export function MessageTimeline(props: {
touchGesture = undefined
}}
onPointerDown={(e) => {
const next = trigger(e.target)
if (next) props.onPreserveScrollAnchor(next)
if (e.target !== e.currentTarget) return
props.onMarkScrollGesture(e.currentTarget)
}}
onKeyDown={(e) => {
if (e.key !== "Enter" && e.key !== " ") return
const next = trigger(e.target)
if (!next) return
props.onPreserveScrollAnchor(next)
}}
onScroll={(e) => {
props.onScheduleScrollState(e.currentTarget)
props.onTurnBackfillScroll()
@@ -401,24 +543,134 @@ export function MessageTimeline(props: {
props.onMarkScrollGesture(e.currentTarget)
if (props.isDesktop) props.onScrollSpyScroll()
}}
onClick={(e) => {
props.onAutoScrollInteraction(e)
}}
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "72px" : "0px",
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<div>
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
<Show when={parentID()}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={titleValue() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-14-medium text-text-strong truncate grow-1 min-w-0 pl-2"
onDblClick={openTitleEditor}
>
{titleValue()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 pl-2 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</Show>
<div
ref={props.setContentRef}
role="log"
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
style={{ "padding-top": "var(--session-title-height)" }}
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
classList={{
"w-full": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
"mt-0.5": props.centered,
"mt-0": !props.centered,
}}
@@ -440,15 +692,6 @@ export function MessageTimeline(props: {
</Show>
<For each={rendered()}>
{(messageID) => {
// Capture at creation time: animate only messages added after the
// timeline finishes its initial backfill staging, plus the first
// turn while a brand new session is still using its default title.
const isNew =
staging.ready() ||
(defaultTitle() &&
sessionStatus() !== "idle" &&
props.renderedUserMessages.length === 1 &&
messageID === props.renderedUserMessages[0]?.id)
const active = createMemo(() => activeMessageID() === messageID)
const queued = createMemo(() => {
if (active()) return false
@@ -457,10 +700,7 @@ export function MessageTimeline(props: {
return false
})
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => {
if (a.length !== b.length) return false
return a.every((x, i) => x.path === b[i].path && x.comment === b[i].comment)
},
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b),
})
const commentCount = createMemo(() => comments().length)
return (
@@ -473,7 +713,7 @@ export function MessageTimeline(props: {
}}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[500px] 2xl:max-w-[700px]": props.centered,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
>
<Show when={commentCount() > 0}>
@@ -517,7 +757,7 @@ export function MessageTimeline(props: {
messageID={messageID}
active={active()}
queued={queued()}
animate={isNew || active()}
status={active() ? sessionStatus() : undefined}
showReasoningSummaries={settings.general.showReasoningSummaries()}
shellToolDefaultOpen={settings.general.shellToolPartsExpanded()}
editToolDefaultOpen={settings.general.editToolPartsExpanded()}
@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
reviewPanel: () => JSX.Element
activeDiff?: string
focusReviewDiff: (path: string) => void
reviewSnap: boolean
size: Sizing
}) {
const params = useParams()
@@ -228,7 +229,7 @@ export function SessionSidePanel(props: {
classList={{
"pointer-events-none": !open(),
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
!props.size.active(),
!props.size.active() && !props.reviewSnap,
}}
style={{ width: panelWidth() }}
>
@@ -1,522 +0,0 @@
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useNavigate, useParams } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"
import { useReducedMotion } from "@opencode-ai/ui/hooks"
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 { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
import { showToast } from "@opencode-ai/ui/toast"
import { errorMessage } from "@/pages/layout/helpers"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
export function SessionTimelineHeader(props: {
centered: boolean
showHeader: () => boolean
sessionKey: () => string
sessionID: () => string | undefined
parentID: () => string | undefined
titleValue: () => string | undefined
headerTitle: () => string | undefined
placeholderTitle: () => boolean
}) {
const navigate = useNavigate()
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const dialog = useDialog()
const language = useLanguage()
const reduce = useReducedMotion()
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
const [headerText, setHeaderText] = createStore({
session: props.sessionKey(),
value: props.headerTitle(),
prev: undefined as string | undefined,
muted: props.placeholderTitle(),
prevMuted: false,
})
let headerAnim: AnimationPlaybackControls | undefined
let enterAnim: AnimationPlaybackControls | undefined
let leaveAnim: AnimationPlaybackControls | undefined
let titleRef: HTMLInputElement | undefined
let headerRef: HTMLDivElement | undefined
let enterRef: HTMLSpanElement | undefined
let leaveRef: HTMLSpanElement | undefined
const clearHeaderAnim = () => {
headerAnim?.stop()
headerAnim = undefined
}
const animateHeader = () => {
const el = headerRef
if (!el) return
clearHeaderAnim()
if (!headerText.muted || reduce()) {
el.style.opacity = "1"
return
}
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
headerAnim.finished.then(() => {
if (headerRef !== el) return
clearFadeStyles(el)
})
}
const clearTitleAnims = () => {
enterAnim?.stop()
enterAnim = undefined
leaveAnim?.stop()
leaveAnim = undefined
}
const settleTitleEnter = () => {
if (enterRef) clearFadeStyles(enterRef)
}
const hideLeave = () => {
if (!leaveRef) return
leaveRef.style.opacity = "0"
leaveRef.style.filter = ""
leaveRef.style.transform = ""
}
const animateEnterSpan = () => {
if (!enterRef) return
if (reduce()) {
settleTitleEnter()
return
}
enterAnim = animate(
enterRef,
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
FAST_SPRING,
)
enterAnim.finished.then(() => settleTitleEnter())
}
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
setHeaderText({ value: nextTitle, muted: nextMuted })
if (reduce()) {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
settleTitleEnter()
return
}
if (leaveRef) {
leaveAnim = animate(
leaveRef,
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
FAST_SPRING,
)
leaveAnim.finished.then(() => {
setHeaderText({ prev: undefined, prevMuted: false })
hideLeave()
})
}
animateEnterSpan()
}
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
animateEnterSpan()
}
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
clearTitleAnims()
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
settleTitleEnter()
}
createEffect(
on(props.showHeader, (show, prev) => {
if (!show) {
clearHeaderAnim()
return
}
if (show === prev) return
animateHeader()
}),
)
createEffect(
on(
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
([nextSession, nextTitle, nextMuted]) => {
if (nextSession !== headerText.session) {
setHeaderText("session", nextSession)
if (nextTitle && nextMuted) {
fadeInTitle(nextTitle, nextMuted)
return
}
snapTitle(nextTitle, nextMuted)
return
}
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
if (!nextTitle) {
snapTitle(undefined, false)
return
}
if (!headerText.value) {
fadeInTitle(nextTitle, nextMuted)
return
}
if (title.saving || title.editing) {
snapTitle(nextTitle, nextMuted)
return
}
crossfadeTitle(nextTitle, nextMuted)
},
),
)
onCleanup(() => {
clearHeaderAnim()
clearTitleAnims()
})
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
createEffect(
on(
props.sessionKey,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!props.sessionID()) return
setTitle({ editing: true, draft: props.titleValue() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const id = props.sessionID()
if (!id) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (props.titleValue() ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID: id, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((session) => session.id === id)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
if (params.id !== sessionID) return
if (parentID) {
navigate(`/${params.dir}/session/${parentID}`)
return
}
if (nextSessionID) {
navigate(`/${params.dir}/session/${nextSessionID}`)
return
}
navigate(`/${params.dir}/session`)
}
const archiveSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((item) => item.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((item) => item.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: toastError(err),
})
})
}
const deleteSession = async (sessionID: string) => {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: toastError(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((item) => !removed.has(item.id))
}),
)
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
return true
}
const navigateParent = () => {
const id = props.parentID()
if (!id) return
navigate(`/${params.dir}/session/${id}`)
}
function DialogDeleteSession(input: { sessionID: string }) {
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(input.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: name() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
return (
<Show when={props.showHeader()}>
<div
data-session-title
ref={(el) => {
headerRef = el
el.style.opacity = "0"
}}
class="pointer-events-none absolute inset-x-0 top-0 z-30"
>
<div
classList={{
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
"w-full": true,
"pb-10": true,
"px-4 md:px-5": true,
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
}}
>
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={props.parentID()}>
<div>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={navigateParent}
aria-label={language.t("common.goBack")}
/>
</div>
</Show>
<Show when={!!headerText.value || title.editing}>
<Show
when={title.editing}
fallback={
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
<span class="grid min-w-0" style={{ overflow: "clip" }}>
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
</span>
<span
ref={leaveRef}
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
style={{ opacity: "0" }}
>
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
</span>
</span>
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
<Show when={props.sessionID()}>
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
<DropdownMenu
gutter={4}
placement="bottom-end"
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{ "min-width": "104px" }}
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle("pendingRename", true)
setTitle("menuOpen", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
</div>
</Show>
)
}
@@ -1,5 +1,6 @@
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { useLocation, useNavigate } from "@solidjs/router"
import { createEffect, createMemo, onMount } from "solid-js"
import { messageIdFromHash } from "./message-id-from-hash"
export { messageIdFromHash } from "./message-id-from-hash"
@@ -15,7 +16,7 @@ export const useSessionHashScroll = (input: {
setPendingMessage: (value: string | undefined) => void
setActiveMessage: (message: UserMessage | undefined) => void
setTurnStart: (value: number) => void
autoScroll: { pause: () => void; snapToBottom: () => void }
autoScroll: { pause: () => void; forceScrollToBottom: () => void }
scroller: () => HTMLDivElement | undefined
anchor: (id: string) => string
scheduleScrollState: (el: HTMLDivElement) => void
@@ -26,13 +27,18 @@ export const useSessionHashScroll = (input: {
const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i])))
let pendingKey = ""
const location = useLocation()
const navigate = useNavigate()
const clearMessageHash = () => {
if (!window.location.hash) return
window.history.replaceState(null, "", window.location.pathname + window.location.search)
if (!location.hash) return
navigate(location.pathname + location.search, { replace: true })
}
const updateHash = (id: string) => {
window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`)
navigate(location.pathname + location.search + `#${input.anchor(id)}`, {
replace: true,
})
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
@@ -41,15 +47,15 @@ export const useSessionHashScroll = (input: {
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const inset = Number.isNaN(title) ? 0 : title
// With column-reverse, scrollTop is negative — don't clamp to 0
const top = a.top - b.top + root.scrollTop - inset
const sticky = root.querySelector("[data-session-title]")
const inset = sticky instanceof HTMLElement ? sticky.offsetHeight : 0
const top = Math.max(0, a.top - b.top + root.scrollTop - inset)
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
console.log({ message, behavior })
if (input.currentMessageId() !== message.id) input.setActiveMessage(message)
const index = messageIndex().get(message.id) ?? -1
@@ -97,9 +103,9 @@ export const useSessionHashScroll = (input: {
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
const hash = location.hash.slice(1)
if (!hash) {
input.autoScroll.snapToBottom()
input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
return
@@ -123,26 +129,13 @@ export const useSessionHashScroll = (input: {
return
}
input.autoScroll.snapToBottom()
input.autoScroll.forceScrollToBottom()
const el = input.scroller()
if (el) input.scheduleScrollState(el)
}
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
const handler = () => {
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
}
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
location.hash
if (!input.sessionID() || !input.messagesReady()) return
requestAnimationFrame(() => applyHash("auto"))
})
@@ -166,6 +159,7 @@ export const useSessionHashScroll = (input: {
}
}
if (!targetId) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (input.currentMessageId() === targetId) return
@@ -177,6 +171,12 @@ export const useSessionHashScroll = (input: {
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual"
}
})
return {
clearMessageHash,
scrollToMessage,
+46
View File
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, test } from "bun:test"
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
const run = () => Function(src)()
beforeEach(() => {
document.head.innerHTML = ""
document.documentElement.removeAttribute("data-theme")
document.documentElement.removeAttribute("data-color-scheme")
localStorage.clear()
Object.defineProperty(window, "matchMedia", {
value: () =>
({
matches: false,
}) as MediaQueryList,
configurable: true,
})
})
describe("theme preload", () => {
test("migrates legacy oc-1 to oc-2 before mount", () => {
localStorage.setItem("opencode-theme-id", "oc-1")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
run()
expect(document.documentElement.dataset.theme).toBe("oc-2")
expect(document.documentElement.dataset.colorScheme).toBe("light")
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
expect(document.getElementById("oc-theme-preload")).toBeNull()
})
test("keeps cached css for non-default themes", () => {
localStorage.setItem("opencode-theme-id", "nightowl")
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
run()
expect(document.documentElement.dataset.theme).toBe("nightowl")
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
})
})
+8
View File
@@ -104,4 +104,12 @@ describe("persist localStorage resilience", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
test("workspace storage sanitizes Windows filename characters", () => {
const result = persistTesting.workspaceStorage("C:\\Users\\foo")
expect(result).toStartWith("opencode.workspace.")
expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse()
})
})
+2 -1
View File
@@ -204,7 +204,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
@@ -300,6 +300,7 @@ export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
workspaceStorage,
}
export const Persist = {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.22",
"version": "1.2.23",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.22",
"version": "1.2.23",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.22",
"version": "1.2.23",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.22"
version = "1.2.23"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.22/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.23/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.22",
"version": "1.2.23",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.22",
"version": "1.2.23",
"name": "opencode",
"type": "module",
"license": "MIT",
+13 -1
View File
@@ -280,6 +280,11 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
})
.option("dir", {
type: "string",
describe: "directory to run in, path on remote server if attaching",
@@ -648,7 +653,14 @@ export const RunCommand = cmd({
}
if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const username = process.env.OPENCODE_SERVER_USERNAME ?? "opencode"
const auth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const sdk = createOpencodeClient({ baseUrl: args.attach, directory, headers })
return await execute(sdk)
}
+17
View File
@@ -20,6 +20,7 @@ import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
import { DialogSessionList } from "@tui/component/dialog-session-list"
import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list"
import { KeybindProvider } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
@@ -371,6 +372,22 @@ function App() {
dialog.replace(() => <DialogSessionList />)
},
},
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
? [
{
title: "Manage workspaces",
value: "workspace.list",
category: "Workspace",
suggested: true,
slash: {
name: "workspaces",
},
onSelect: () => {
dialog.replace(() => <DialogWorkspaceList />)
},
},
]
: []),
{
title: "New session",
suggested: route.data.type === "session",
@@ -0,0 +1,326 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import type { Session } from "@opencode-ai/sdk/v2"
import { useSDK } from "../context/sdk"
import { useToast } from "../ui/toast"
import { useKeybind } from "../context/keybind"
import { DialogSessionList } from "./workspace/dialog-session-list"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
async function openWorkspace(input: {
dialog: ReturnType<typeof useDialog>
route: ReturnType<typeof useRoute>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
toast: ReturnType<typeof useToast>
workspaceID: string
forceCreate?: boolean
}) {
const cacheSession = (session: Session) => {
input.sync.set(
"session",
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
a.id.localeCompare(b.id),
),
)
}
const client = createOpencodeClient({
baseUrl: input.sdk.url,
fetch: input.sdk.fetch,
directory: input.sync.data.path.directory || input.sdk.directory,
experimental_workspaceID: input.workspaceID,
})
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
const session = listed?.data?.[0]
if (session?.id) {
cacheSession(session)
input.route.navigate({
type: "session",
sessionID: session.id,
})
input.dialog.clear()
return
}
let created: Session | undefined
while (!created) {
const result = await client.session.create({}).catch(() => undefined)
if (!result) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
if (result.response.status >= 500 && result.response.status < 600) {
await Bun.sleep(1000)
continue
}
if (!result.data) {
input.toast.show({
message: "Failed to open workspace",
variant: "error",
})
return
}
created = result.data
}
cacheSession(created)
input.route.navigate({
type: "session",
sessionID: created.id,
})
input.dialog.clear()
}
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
const dialog = useDialog()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const [creating, setCreating] = createSignal<string>()
onMount(() => {
dialog.setSize("medium")
})
const options = createMemo(() => {
const type = creating()
if (type) {
return [
{
title: `Creating ${type} workspace...`,
value: "creating" as const,
description: "This can take a while for remote environments",
},
]
}
return [
{
title: "Worktree",
value: "worktree" as const,
description: "Create a local git worktree",
},
]
})
const createWorkspace = async (type: string) => {
if (creating()) return
setCreating(type)
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
console.log(err)
return undefined
})
console.log(JSON.stringify(result, null, 2))
const workspace = result?.data
if (!workspace) {
setCreating(undefined)
toast.show({
message: "Failed to create workspace",
variant: "error",
})
return
}
await sync.workspace.sync()
await props.onSelect(workspace.id)
setCreating(undefined)
}
return (
<DialogSelect
title={creating() ? "Creating Workspace" : "New Workspace"}
skipFilter={true}
options={options()}
onSelect={(option) => {
if (option.value === "creating") return
void createWorkspace(option.value)
}}
/>
)
}
export function DialogWorkspaceList() {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const sdk = useSDK()
const toast = useToast()
const keybind = useKeybind()
const [toDelete, setToDelete] = createSignal<string>()
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
const open = (workspaceID: string, forceCreate?: boolean) =>
openWorkspace({
dialog,
route,
sdk,
sync,
toast,
workspaceID,
forceCreate,
})
async function selectWorkspace(workspaceID: string) {
if (workspaceID === "__local__") {
if (localCount() > 0) {
dialog.replace(() => <DialogSessionList localOnly={true} />)
return
}
route.navigate({
type: "home",
})
dialog.clear()
return
}
const count = counts()[workspaceID]
if (count && count > 0) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
if (count === 0) {
await open(workspaceID)
return
}
const client = createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspaceID,
})
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
if (listed?.data?.length) {
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
return
}
await open(workspaceID)
}
const currentWorkspaceID = createMemo(() => {
if (route.data.type === "session") {
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
}
return "__local__"
})
const localCount = createMemo(
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
)
let run = 0
createEffect(() => {
const workspaces = sync.data.workspaceList
const next = ++run
if (!workspaces.length) {
setCounts({})
return
}
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
void Promise.all(
workspaces.map(async (workspace) => {
const client = createOpencodeClient({
baseUrl: sdk.url,
fetch: sdk.fetch,
directory: sync.data.path.directory || sdk.directory,
experimental_workspaceID: workspace.id,
})
const result = await client.session.list({ roots: true }).catch(() => undefined)
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
}),
).then((entries) => {
if (run !== next) return
setCounts(Object.fromEntries(entries))
})
})
const options = createMemo(() => [
{
title: "Local",
value: "__local__",
category: "Workspace",
description: "Use the local machine",
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
},
...sync.data.workspaceList.map((workspace) => {
const count = counts()[workspace.id]
return {
title:
toDelete() === workspace.id
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
: workspace.id,
value: workspace.id,
category: workspace.type,
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
footer:
count === undefined
? "Loading sessions..."
: count === null
? "Sessions unavailable"
: `${count} session${count === 1 ? "" : "s"}`,
}
}),
{
title: "+ New workspace",
value: "__create__",
category: "Actions",
description: "Create a new workspace",
},
])
onMount(() => {
dialog.setSize("large")
void sync.workspace.sync()
})
return (
<DialogSelect
title="Workspaces"
skipFilter={true}
options={options()}
current={currentWorkspaceID()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
setToDelete(undefined)
if (option.value === "__create__") {
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
return
}
void selectWorkspace(option.value)
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (option.value === "__create__" || option.value === "__local__") return
if (toDelete() !== option.value) {
setToDelete(option.value)
return
}
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
setToDelete(undefined)
if (result?.error) {
toast.show({
message: "Failed to delete workspace",
variant: "error",
})
return
}
if (currentWorkspaceID() === option.value) {
route.navigate({
type: "home",
})
}
await sync.workspace.sync()
},
},
]}
/>
)
}
@@ -0,0 +1,151 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { useKeybind } from "../../context/keybind"
import { useTheme } from "../../context/theme"
import { useSDK } from "../../context/sdk"
import { DialogSessionRename } from "../dialog-session-rename"
import { useKV } from "../../context/kv"
import { createDebouncedSignal } from "../../util/signal"
import { Spinner } from "../spinner"
import { useToast } from "../../ui/toast"
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
const dialog = useDialog()
const route = useRoute()
const sync = useSync()
const keybind = useKeybind()
const { theme } = useTheme()
const sdk = useSDK()
const kv = useKV()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
const [search, setSearch] = createDebouncedSignal("", 150)
const [listed, listedActions] = createResource(
() => props.workspaceID,
async (workspaceID) => {
if (!workspaceID) return undefined
const result = await sdk.client.session.list({ roots: true })
return result.data ?? []
},
)
const [searchResults] = createResource(search, async (query) => {
if (!query || props.localOnly) return undefined
const result = await sdk.client.session.list({
search: query,
limit: 30,
...(props.workspaceID ? { roots: true } : {}),
})
return result.data ?? []
})
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const sessions = createMemo(() => {
if (searchResults()) return searchResults()!
if (props.workspaceID) return listed() ?? []
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
return sync.data.session
})
const options = createMemo(() => {
const today = new Date().toDateString()
return sessions()
.filter((x) => {
if (x.parentID !== undefined) return false
if (props.workspaceID && listed()) return true
if (props.workspaceID) return x.workspaceID === props.workspaceID
if (props.localOnly) return !x.workspaceID
return true
})
.toSorted((a, b) => b.time.updated - a.time.updated)
.map((x) => {
const date = new Date(x.time.updated)
let category = date.toDateString()
if (category === today) {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
bg: isDeleting ? theme.error : undefined,
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <Spinner /> : undefined,
}
})
})
onMount(() => {
dialog.setSize("large")
})
return (
<DialogSelect
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
options={options()}
skipFilter={!props.localOnly}
current={currentSessionID()}
onFilter={setSearch}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
route.navigate({
type: "session",
sessionID: option.value,
})
dialog.clear()
}}
keybind={[
{
keybind: keybind.all.session_delete?.[0],
title: "delete",
onTrigger: async (option) => {
if (toDelete() === option.value) {
const deleted = await sdk.client.session
.delete({
sessionID: option.value,
})
.then(() => true)
.catch(() => false)
setToDelete(undefined)
if (!deleted) {
toast.show({
message: "Failed to delete session",
variant: "error",
})
return
}
if (props.workspaceID) {
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
return
}
sync.set(
"session",
sync.data.session.filter((session) => session.id !== option.value),
)
return
}
setToDelete(option.value)
},
},
{
keybind: keybind.all.session_rename?.[0],
title: "rename",
onTrigger: async (option) => {
dialog.replace(() => <DialogSessionRename session={option.value} />)
},
},
]}
/>
)
}
@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
on: (handler: (event: Event) => void) => () => void
setWorkspace?: (workspaceID?: string) => void
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
events?: EventSource
}) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
})
let workspaceID: string | undefined
let sse: AbortController | undefined
function createSDK() {
return createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
experimental_workspaceID: workspaceID,
})
}
let sdk = createSDK()
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
flush()
}
onMount(async () => {
// If an event source is provided, use it instead of SSE
function startSSE() {
sse?.abort()
const ctrl = new AbortController()
sse = ctrl
;(async () => {
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
handleEvent(event)
}
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
}
})().catch(() => {})
}
onMount(() => {
if (props.events) {
const unsub = props.events.on(handleEvent)
onCleanup(unsub)
return
}
// Fall back to SSE
while (true) {
if (abort.signal.aborted) break
const events = await sdk.event.subscribe(
{},
{
signal: abort.signal,
},
)
for await (const event of events.stream) {
handleEvent(event)
}
// Flush any remaining events
if (timer) clearTimeout(timer)
if (queue.length > 0) {
flush()
}
} else {
startSSE()
}
})
onCleanup(() => {
abort.abort()
sse?.abort()
if (timer) clearTimeout(timer)
})
return { client: sdk, event: emitter, url: props.url }
return {
get client() {
return sdk
},
directory: props.directory,
event: emitter,
fetch: props.fetch ?? fetch,
setWorkspace(next?: string) {
if (workspaceID === next) return
workspaceID = next
sdk = createSDK()
props.events?.setWorkspace?.(next)
if (!props.events) startSSE()
},
url: props.url,
}
},
})
@@ -28,6 +28,7 @@ import { useArgs } from "./args"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
import type { Workspace } from "@opencode-ai/sdk/v2"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
formatter: FormatterStatus[]
vcs: VcsInfo | undefined
path: Path
workspaceList: Workspace[]
}>({
provider_next: {
all: [],
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
formatter: [],
vcs: undefined,
path: { state: "", config: "", worktree: "", directory: "" },
workspaceList: [],
})
const sdk = useSDK()
async function syncWorkspaces() {
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
if (!result?.data) return
setStore("workspaceList", reconcile(result.data))
}
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
syncWorkspaces(),
]).then(() => {
setStore("status", "complete")
})
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
fullSyncedSessions.add(sessionID)
},
},
workspace: {
get(workspaceID: string) {
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
},
sync: syncWorkspaces,
},
bootstrap,
}
return result
@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useCommandDialog } from "@tui/component/dialog-command"
import { useKeybind } from "../../context/keybind"
import { Flag } from "@/flag/flag"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor<Session> }) => {
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
)
}
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
const { theme } = useTheme()
return (
<Show when={props.workspace()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.workspace()}
</text>
</Show>
)
}
export function Header() {
const route = useRouteData("session")
const sync = useSync()
@@ -59,6 +71,14 @@ export function Header() {
return result
})
const workspace = createMemo(() => {
const id = session()?.workspaceID
if (!id) return "Workspace local"
const info = sync.workspace.get(id)
if (!info) return `Workspace ${id}`
return `Workspace ${id} (${info.type})`
})
const { theme } = useTheme()
const keybind = useKeybind()
const command = useCommandDialog()
@@ -83,9 +103,19 @@ export function Header() {
<Match when={session()?.parentID}>
<box flexDirection="column" gap={1}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
<box flexDirection="column">
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<WorkspaceInfo workspace={workspace} />
</box>
) : (
<text fg={theme.text}>
<b>Subagent session</b>
</text>
)}
<ContextInfo context={context} cost={cost} />
</box>
<box flexDirection="row" gap={2}>
@@ -124,7 +154,14 @@ export function Header() {
</Match>
<Match when={true}>
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
<Title session={session} />
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
<box flexDirection="column">
<Title session={session} />
<WorkspaceInfo workspace={workspace} />
</box>
) : (
<Title session={session} />
)}
<ContextInfo context={context} cost={cost} />
</box>
</Match>
@@ -182,6 +182,12 @@ export function Session() {
return new CustomSpeedScroll(3)
})
createEffect(() => {
if (session()?.workspaceID) {
sdk.setWorkspace(session()?.workspaceID)
}
})
createEffect(async () => {
await sync.session
.sync(route.sessionID)
@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
function createEventSource(client: RpcClient): EventSource {
return {
on: (handler) => client.on<Event>("event", handler),
setWorkspace: (workspaceID) => {
void client.call("setWorkspace", { workspaceID })
},
}
}
+7 -3
View File
@@ -44,7 +44,7 @@ const eventStream = {
abort: undefined as AbortController | undefined,
}
const startEventStream = (directory: string) => {
const startEventStream = (input: { directory: string; workspaceID?: string }) => {
if (eventStream.abort) eventStream.abort.abort()
const abort = new AbortController()
eventStream.abort = abort
@@ -59,7 +59,8 @@ const startEventStream = (directory: string) => {
const sdk = createOpencodeClient({
baseUrl: "http://opencode.internal",
directory,
directory: input.directory,
experimental_workspaceID: input.workspaceID,
fetch: fetchFn,
signal,
})
@@ -95,7 +96,7 @@ const startEventStream = (directory: string) => {
})
}
startEventStream(process.cwd())
startEventStream({ directory: process.cwd() })
export const rpc = {
async fetch(input: { url: string; method: string; headers: Record<string, string>; body?: string }) {
@@ -135,6 +136,9 @@ export const rpc = {
Config.global.reset()
await Instance.disposeAll()
},
async setWorkspace(input: { workspaceID?: string }) {
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
},
async shutdown() {
Log.Default.info("worker shutting down")
if (eventStream.abort) eventStream.abort.abort()
+2
View File
@@ -57,6 +57,8 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_WORKSPACES_TUI =
OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WORKSPACES_TUI")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
-5
View File
@@ -46,11 +46,6 @@ process.on("uncaughtException", (e) => {
})
})
// Ensure the process exits on terminal hangup (eg. closing the terminal tab).
// Without this, long-running commands like `serve` block on a never-resolving
// promise and survive as orphaned processes.
process.on("SIGHUP", () => process.exit())
let cli = yargs(hideBin(process.argv))
.parserConfiguration({ "populate--": true })
.scriptName("opencode")
+1 -6
View File
@@ -480,6 +480,7 @@ export namespace Provider {
const aiGatewayHeaders = {
"User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
"anthropic-beta": "context-1m-2025-08-07",
...(providerConfig?.options?.aiGatewayHeaders || {}),
}
@@ -1288,12 +1289,6 @@ export namespace Provider {
}
}
// Check if opencode provider is available before using it
const opencodeProvider = await state().then((state) => state.providers["opencode"])
if (opencodeProvider && opencodeProvider.models["gpt-5-nano"]) {
return getModel("opencode", "gpt-5-nano")
}
return undefined
}
+3 -1
View File
@@ -440,7 +440,9 @@ export namespace ProviderTransform {
const copilotEfforts = iife(() => {
if (id.includes("5.1-codex-max") || id.includes("5.2") || id.includes("5.3"))
return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
return WIDELY_SUPPORTED_EFFORTS
const arr = [...WIDELY_SUPPORTED_EFFORTS]
if (id.includes("gpt-5") && model.release_date >= "2025-12-04") arr.push("xhigh")
return arr
})
return Object.fromEntries(
copilotEfforts.map((effort) => [
@@ -198,6 +198,30 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
})
})
test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("GITLAB_TOKEN", "test-token")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["gitlab"]).toBeDefined()
expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
},
})
})
test("GitLab Duo: supports feature flags configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -2002,6 +2002,35 @@ describe("ProviderTransform.variants", () => {
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
})
test("gpt-5.3-codex includes xhigh", () => {
const model = createMockModel({
id: "gpt-5.3-codex",
providerID: "github-copilot",
api: {
id: "gpt-5.3-codex",
url: "https://api.githubcopilot.com",
npm: "@ai-sdk/github-copilot",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
})
test("gpt-5.4 includes xhigh", () => {
const model = createMockModel({
id: "gpt-5.4",
release_date: "2026-03-05",
providerID: "github-copilot",
api: {
id: "gpt-5.4",
url: "https://api.githubcopilot.com",
npm: "@ai-sdk/github-copilot",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
})
})
describe("@ai-sdk/cerebras", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"scripts": {
+8 -1
View File
@@ -5,7 +5,7 @@ import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
export function createOpencodeClient(config?: Config & { directory?: string }) {
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
if (!config?.fetch) {
const customFetch: any = (req: any) => {
// @ts-ignore
@@ -27,6 +27,13 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
}
}
if (config?.experimental_workspaceID) {
config.headers = {
...config.headers,
"x-opencode-workspace": config.experimental_workspaceID,
}
}
const client = createClient(config)
return new OpencodeClient({ client })
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"scripts": {
+1 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.22",
"version": "1.2.23",
"type": "module",
"license": "MIT",
"exports": {
@@ -48,11 +48,8 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/lifecycle": "0.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/page-visibility": "2.1.1",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/rootless": "1.5.2",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"dompurify": "3.3.1",
@@ -9,20 +9,19 @@
display: inline-flex;
flex-direction: row-reverse;
align-items: baseline;
justify-content: flex-start;
justify-content: flex-end;
line-height: inherit;
width: var(--animated-number-width, 1ch);
overflow: clip;
transition: width var(--tool-motion-spring-ms, 800ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
overflow: hidden;
transition: width var(--tool-motion-spring-ms, 560ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="animated-number-digit"] {
display: inline-block;
flex-shrink: 0;
width: 1ch;
height: 1em;
line-height: 1em;
overflow: clip;
overflow: hidden;
vertical-align: baseline;
-webkit-mask-image: linear-gradient(
to bottom,
@@ -47,7 +46,7 @@
flex-direction: column;
transform: translateY(calc(var(--animated-number-offset, 10) * -1em));
transition-property: transform;
transition-duration: var(--animated-number-duration, 600ms);
transition-duration: var(--animated-number-duration, 560ms);
transition-timing-function: var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
+2 -27
View File
@@ -1,7 +1,7 @@
import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js"
const TRACK = Array.from({ length: 30 }, (_, index) => index % 10)
const DURATION = 800
const DURATION = 600
function normalize(value: number) {
return ((value % 10) + 10) % 10
@@ -90,35 +90,10 @@ export function AnimatedNumber(props: { value: number; class?: string }) {
)
const width = createMemo(() => `${digits().length}ch`)
const [exitingDigits, setExitingDigits] = createSignal<number[]>([])
let exitTimer: number | undefined
createEffect(
on(
digits,
(current, prev) => {
if (prev && current.length < prev.length) {
setExitingDigits(prev.slice(current.length))
clearTimeout(exitTimer)
exitTimer = window.setTimeout(() => setExitingDigits([]), DURATION)
} else {
clearTimeout(exitTimer)
setExitingDigits([])
}
},
{ defer: true },
),
)
const displayDigits = createMemo(() => {
const exiting = exitingDigits()
return exiting.length ? [...digits(), ...exiting] : digits()
})
return (
<span data-component="animated-number" class={props.class} aria-label={label()}>
<span data-slot="animated-number-value" style={{ "--animated-number-width": width() }}>
<Index each={displayDigits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
<Index each={digits()}>{(digit) => <Digit value={digit()} direction={direction()} />}</Index>
</span>
</span>
)
+47 -13
View File
@@ -8,28 +8,54 @@
justify-content: flex-start;
[data-slot="basic-tool-tool-trigger-content"] {
width: 100%;
min-width: 0;
width: auto;
display: flex;
align-items: center;
align-self: stretch;
gap: 8px;
}
[data-slot="basic-tool-tool-indicator"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="basic-tool-tool-spinner"] {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-weak);
[data-component="spinner"] {
width: 16px;
height: 16px;
}
}
[data-slot="icon-svg"] {
flex-shrink: 0;
}
[data-slot="basic-tool-tool-info"] {
flex: 1 1 auto;
flex: 0 1 auto;
min-width: 0;
font-size: 14px;
}
[data-slot="basic-tool-tool-info-structured"] {
width: auto;
max-width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -37,12 +63,11 @@
}
[data-slot="basic-tool-tool-info-main"] {
flex: 0 1 auto;
display: flex;
align-items: center;
align-items: baseline;
gap: 8px;
min-width: 0;
overflow: clip;
overflow: hidden;
}
[data-slot="basic-tool-tool-title"] {
@@ -54,14 +79,22 @@
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
&.capitalize {
text-transform: capitalize;
}
&.agent-title {
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
}
[data-slot="basic-tool-tool-subtitle"] {
display: inline-block;
flex: 0 1 auto;
max-width: 100%;
flex-shrink: 1;
min-width: 0;
overflow: clip;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
@@ -106,7 +139,8 @@
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
overflow: clip;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-variant-numeric: tabular-nums;
+97 -259
View File
@@ -1,20 +1,8 @@
import {
createEffect,
createSignal,
For,
Match,
on,
onCleanup,
onMount,
Show,
splitProps,
Switch,
type JSX,
} from "solid-js"
import { animate, type AnimationPlaybackControls, tunableSpringValue, COLLAPSIBLE_SPRING } from "./motion"
import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { Collapsible } from "./collapsible"
import type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
import { hold } from "./tool-utils"
export type TriggerTitle = {
title: string
@@ -32,99 +20,26 @@ const isTriggerTitle = (val: any): val is TriggerTitle => {
)
}
interface ToolCallPanelBaseProps {
icon: string
export interface BasicToolProps {
icon: IconProps["name"]
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
animate?: boolean
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
defer?: boolean
locked?: boolean
watchDetails?: boolean
springContent?: boolean
animated?: boolean
onSubtitleClick?: () => void
}
function ToolCallTriggerBody(props: {
trigger: TriggerTitle | JSX.Element
pending: boolean
onSubtitleClick?: () => void
arrow?: boolean
}) {
return (
<div data-component="tool-trigger" data-arrow={props.arrow ? "" : undefined}>
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={props.pending} />
</span>
<Show when={!props.pending}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (!props.onSubtitleClick) return
e.stopPropagation()
props.onSubtitleClick()
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!props.pending && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.arrow}>
<Collapsible.Arrow />
</Show>
</div>
)
}
const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 }
function ToolCallPanel(props: ToolCallPanelBaseProps) {
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [ready, setReady] = createSignal(open())
const pendingRaw = () => props.status === "pending" || props.status === "running"
const pending = hold(pendingRaw, 1000)
const watchDetails = () => props.watchDetails !== false
const pending = () => props.status === "pending" || props.status === "running"
let frame: number | undefined
@@ -144,7 +59,7 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
on(
open,
(value) => {
if (!props.defer || props.springContent) return
if (!props.defer) return
if (!value) {
cancel()
setReady(false)
@@ -162,110 +77,36 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
),
)
// Animated content height — single springValue drives all height changes
// Animated height for collapsible open/close
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let observer: ResizeObserver | undefined
let resizeFrame: number | undefined
let heightAnim: AnimationPlaybackControls | undefined
const initialOpen = open()
const heightSpring = tunableSpringValue<number>(0, COLLAPSIBLE_SPRING)
const read = () => Math.max(0, Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0))
const doOpen = () => {
if (!contentRef || !bodyRef) return
contentRef.style.display = ""
// Ensure fade starts from 0 if content was hidden (first open or after close cleared styles)
if (bodyRef.style.opacity === "") {
bodyRef.style.opacity = "0"
bodyRef.style.filter = "blur(2px)"
}
const next = read()
fadeAnim?.stop()
fadeAnim = animate(bodyRef, { opacity: 1, filter: "blur(0px)" }, COLLAPSIBLE_SPRING)
fadeAnim.finished.then(() => {
if (!bodyRef) return
bodyRef.style.opacity = ""
bodyRef.style.filter = ""
})
heightSpring.set(next)
}
const doClose = () => {
if (!contentRef || !bodyRef) return
fadeAnim?.stop()
fadeAnim = animate(bodyRef, { opacity: 0, filter: "blur(2px)" }, COLLAPSIBLE_SPRING)
fadeAnim.finished.then(() => {
if (!contentRef || open()) return
contentRef.style.display = "none"
})
heightSpring.set(0)
}
const grow = () => {
if (!contentRef || !open()) return
const next = read()
if (Math.abs(next - heightSpring.get()) < 1) return
heightSpring.set(next)
}
onMount(() => {
if (!props.springContent || props.animate === false || !contentRef || !bodyRef) return
const offChange = heightSpring.on("change", (v) => {
if (!contentRef) return
contentRef.style.height = `${Math.max(0, Math.ceil(v))}px`
})
onCleanup(() => {
offChange()
})
if (watchDetails()) {
observer = new ResizeObserver(() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
grow()
})
})
observer.observe(bodyRef)
}
if (!open()) return
if (contentRef.style.display !== "none") {
const next = read()
heightSpring.jump(next)
contentRef.style.height = `${next}px`
return
}
let mountFrame: number | undefined = requestAnimationFrame(() => {
mountFrame = undefined
if (!open()) return
doOpen()
})
onCleanup(() => {
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
})
})
createEffect(
on(
open,
(isOpen) => {
if (!props.springContent || props.animate === false || !contentRef) return
if (isOpen) doOpen()
else doClose()
if (!props.animated || !contentRef) return
heightAnim?.stop()
if (isOpen) {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "auto" }, SPRING)
heightAnim.finished.then(() => {
if (!contentRef || !open()) return
contentRef.style.overflow = "visible"
contentRef.style.height = "auto"
})
} else {
contentRef.style.overflow = "hidden"
heightAnim = animate(contentRef, { height: "0px" }, SPRING)
}
},
{ defer: true },
),
)
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
observer?.disconnect()
fadeAnim?.stop()
heightSpring.destroy()
heightAnim?.stop()
})
const handleOpenChange = (value: boolean) => {
@@ -277,34 +118,85 @@ function ToolCallPanel(props: ToolCallPanelBaseProps) {
return (
<Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible">
<Collapsible.Trigger>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.children && !props.hideDetails && !props.locked && !pending()}
/>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
<div data-slot="basic-tool-tool-info">
<Switch>
<Match when={isTriggerTitle(props.trigger) && props.trigger}>
{(trigger) => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span
data-slot="basic-tool-tool-title"
classList={{
[trigger().titleClass ?? ""]: !!trigger().titleClass,
}}
>
<TextShimmer text={trigger().title} active={pending()} />
</span>
<Show when={!pending()}>
<Show when={trigger().subtitle}>
<span
data-slot="basic-tool-tool-subtitle"
classList={{
[trigger().subtitleClass ?? ""]: !!trigger().subtitleClass,
clickable: !!props.onSubtitleClick,
}}
onClick={(e) => {
if (props.onSubtitleClick) {
e.stopPropagation()
props.onSubtitleClick()
}
}}
>
{trigger().subtitle}
</span>
</Show>
<Show when={trigger().args?.length}>
<For each={trigger().args}>
{(arg) => (
<span
data-slot="basic-tool-tool-arg"
classList={{
[trigger().argsClass ?? ""]: !!trigger().argsClass,
}}
>
{arg}
</span>
)}
</For>
</Show>
</Show>
</div>
<Show when={!pending() && trigger().action}>{trigger().action}</Show>
</div>
)}
</Match>
<Match when={true}>{props.trigger as JSX.Element}</Match>
</Switch>
</div>
</div>
<Show when={props.children && !props.hideDetails && !props.locked && !pending()}>
<Collapsible.Arrow />
</Show>
</div>
</Collapsible.Trigger>
<Show when={props.springContent && props.animate !== false && props.children && !props.hideDetails}>
<Show when={props.animated && props.children && !props.hideDetails}>
<div
ref={contentRef}
data-slot="collapsible-content"
data-spring-content
data-animated
style={{
height: initialOpen ? "auto" : "0px",
overflow: "hidden",
display: initialOpen ? undefined : "none",
overflow: initialOpen ? "visible" : "hidden",
}}
>
<div ref={bodyRef} data-slot="basic-tool-content-inner">
{props.children}
</div>
{props.children}
</div>
</Show>
<Show when={(!props.springContent || props.animate === false) && props.children && !props.hideDetails}>
<Show when={!props.animated && props.children && !props.hideDetails}>
<Collapsible.Content>
<Show when={!props.defer || ready()}>
<div data-slot="basic-tool-content-inner">{props.children}</div>
</Show>
<Show when={!props.defer || ready()}>{props.children}</Show>
</Collapsible.Content>
</Show>
</Collapsible>
@@ -330,60 +222,6 @@ function args(input: Record<string, unknown> | undefined) {
.slice(0, 3)
}
export interface ToolCallRowProps {
variant: "row"
icon: string
trigger: TriggerTitle | JSX.Element
status?: string
animate?: boolean
onSubtitleClick?: () => void
open?: boolean
showArrow?: boolean
onOpenChange?: (value: boolean) => void
}
export interface ToolCallPanelProps extends Omit<ToolCallPanelBaseProps, "hideDetails"> {
variant: "panel"
}
export type ToolCallProps = ToolCallRowProps | ToolCallPanelProps
function ToolCallRoot(props: ToolCallProps) {
const pending = () => props.status === "pending" || props.status === "running"
if (props.variant === "row") {
return (
<Show
when={props.onOpenChange}
fallback={
<div data-component="collapsible" data-variant="normal" class="tool-collapsible">
<div data-slot="collapsible-trigger">
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
/>
</div>
</div>
}
>
{(onOpenChange) => (
<Collapsible open={props.open ?? true} onOpenChange={onOpenChange()} class="tool-collapsible">
<Collapsible.Trigger>
<ToolCallTriggerBody
trigger={props.trigger}
pending={pending()}
onSubtitleClick={props.onSubtitleClick}
arrow={!!props.showArrow}
/>
</Collapsible.Trigger>
</Collapsible>
)}
</Show>
)
}
const [, rest] = splitProps(props, ["variant"])
return <ToolCallPanel {...rest} />
}
export const ToolCall = ToolCallRoot
export function GenericTool(props: {
tool: string
status?: string
@@ -391,8 +229,7 @@ export function GenericTool(props: {
input?: Record<string, unknown>
}) {
return (
<ToolCall
variant={props.hideDetails ? "row" : "panel"}
<BasicTool
icon="mcp"
status={props.status}
trigger={{
@@ -400,6 +237,7 @@ export function GenericTool(props: {
subtitle: label(props.input),
args: args(props.input),
}}
hideDetails={props.hideDetails}
/>
)
}
+32 -23
View File
@@ -8,18 +8,14 @@
border-radius: var(--radius-md);
overflow: visible;
&.tool-collapsible [data-slot="collapsible-trigger"] {
height: 37px;
}
&.tool-collapsible [data-slot="basic-tool-content-inner"] {
padding-top: 0;
&.tool-collapsible {
gap: 8px;
}
[data-slot="collapsible-trigger"] {
width: 100%;
display: flex;
height: 36px;
height: 32px;
padding: 0;
align-items: center;
align-self: stretch;
@@ -27,17 +23,6 @@
user-select: none;
color: var(--text-base);
> [data-component="tool-trigger"][data-arrow] {
width: auto;
max-width: 100%;
flex: 0 1 auto;
[data-slot="basic-tool-tool-trigger-content"] {
width: auto;
max-width: 100%;
}
}
[data-slot="collapsible-arrow"] {
opacity: 0;
transition: opacity 0.15s ease;
@@ -65,6 +50,9 @@
line-height: var(--line-height-large); /* 166.667% */
letter-spacing: var(--letter-spacing-normal);
/* &:hover { */
/* background-color: var(--surface-base); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -94,16 +82,16 @@
}
[data-slot="collapsible-content"] {
overflow: clip;
overflow: hidden;
/* animation: slideUp 250ms ease-out; */
&[data-expanded] {
overflow: visible;
}
/* JS-animated content: overflow managed by animate() */
&[data-spring-content] {
overflow: clip;
}
/* &[data-expanded] { */
/* animation: slideDown 250ms ease-out; */
/* } */
}
&[data-variant="ghost"] {
@@ -115,6 +103,9 @@
border: none;
padding: 0;
/* &:hover { */
/* color: var(--text-strong); */
/* } */
&:focus-visible {
outline: none;
background-color: var(--surface-raised-base-hover);
@@ -131,3 +122,21 @@
}
}
}
@keyframes slideDown {
from {
height: 0;
}
to {
height: var(--kb-collapsible-content-height);
}
}
@keyframes slideUp {
from {
height: var(--kb-collapsible-content-height);
}
to {
height: 0;
}
}
@@ -1,197 +0,0 @@
import { createMemo, createSignal, For, onMount } from "solid-js"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { getFilename } from "@opencode-ai/util/path"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n"
import { ToolCall } from "./basic-tool"
import { ToolStatusTitle } from "./tool-status-title"
import { AnimatedCountList } from "./tool-count-summary"
import { RollingResults } from "./rolling-results"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import { busy, updateScrollMask, useCollapsible, useRowWipe } from "./tool-utils"
function contextToolLabel(part: ToolPart): { action: string; detail: string } {
const state = part.state
const title = "title" in state ? (state.title as string | undefined) : undefined
const input = state.input
if (part.tool === "read") {
const path = input?.filePath as string | undefined
return { action: "Read", detail: title || (path ? getFilename(path) : "") }
}
if (part.tool === "grep") {
const pattern = input?.pattern as string | undefined
return { action: "Search", detail: title || (pattern ? `"${pattern}"` : "") }
}
if (part.tool === "glob") {
const pattern = input?.pattern as string | undefined
return { action: "Find", detail: title || (pattern ?? "") }
}
if (part.tool === "list") {
const path = input?.path as string | undefined
return { action: "List", detail: title || (path ? getFilename(path) : "") }
}
return { action: part.tool, detail: title || "" }
}
function contextToolSummary(parts: ToolPart[]) {
let read = 0
let search = 0
let list = 0
for (const part of parts) {
if (part.tool === "read") read++
else if (part.tool === "glob" || part.tool === "grep") search++
else if (part.tool === "list") list++
}
return { read, search, list }
}
export function ContextToolGroupHeader(props: {
parts: ToolPart[]
pending: boolean
open: boolean
onOpenChange: (value: boolean) => void
}) {
const i18n = useI18n()
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<ToolCall
variant="row"
icon="magnifying-glass-menu"
open={props.open}
showArrow
onOpenChange={props.onOpenChange}
trigger={
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={props.pending}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{
key: "read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
/>
</span>
</span>
</div>
}
/>
)
}
export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: boolean }) {
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
const updateMask = () => {
if (scrollRef) updateScrollMask(scrollRef)
}
useCollapsible({
content: () => contentRef,
body: () => bodyRef,
open: () => props.expanded,
onOpen: updateMask,
})
return (
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
<div ref={bodyRef}>
<div ref={scrollRef} data-component="context-tool-expanded-list" onScroll={updateMask}>
<For each={props.parts}>
{(part) => {
const label = createMemo(() => contextToolLabel(part))
return (
<div data-component="context-tool-expanded-row">
<span data-slot="context-tool-expanded-action">{label().action}</span>
<span data-slot="context-tool-expanded-detail">{label().detail}</span>
</div>
)
}}
</For>
</div>
</div>
</div>
)
}
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
const reduce = useReducedMotion()
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
onMount(() => setMounted(true))
const show = () => mounted() && props.pending
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
return (
<div style={{ opacity: reduce() ? (show() ? 1 : 0) : opacity(), filter: `blur(${reduce() ? 0 : blur()}px)` }}>
<RollingResults
items={props.parts}
rows={5}
rowHeight={22}
rowGap={0}
open={props.pending}
animate
getKey={(part) => part.callID || part.id}
render={(part) => {
const label = createMemo(() => contextToolLabel(part))
const k = part.callID || part.id
return (
<div data-component="context-tool-rolling-row">
<span data-slot="context-tool-rolling-action">{label().action}</span>
{(() => {
const [detailRef, setDetailRef] = createSignal<HTMLSpanElement>()
useRowWipe({
id: () => k,
text: () => label().detail,
ref: detailRef,
seen: wiped,
})
return (
<span
ref={setDetailRef}
data-slot="context-tool-rolling-detail"
style={{ display: label().detail ? undefined : "none" }}
>
{label().detail}
</span>
)
})()}
</div>
)
}}
/>
</div>
)
}
-432
View File
@@ -1,432 +0,0 @@
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
export interface GrowBoxProps {
children: JSX.Element
/** Enable animation. When false, content shows immediately at full height. */
animate?: boolean
/** Animate height from 0 to content height. Default: true. */
grow?: boolean
/** Keep watching body size and animate subsequent height changes. Default: false. */
watch?: boolean
/** Fade in body content (opacity + blur). Default: true. */
fade?: boolean
/** Top padding in px on the body wrapper. Default: 0. */
gap?: number
/** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
autoHeight?: boolean
/** Controlled visibility for animating open/close without unmounting children. */
open?: boolean
/** Animate controlled open/close changes after mount. Default: true. */
animateToggle?: boolean
/** data-slot attribute on the root div. */
slot?: string
/** CSS class on the root div. */
class?: string
/** Override mount and resize spring config. Default: GROW_SPRING. */
spring?: SpringConfig
/** Override controlled open/close spring config. Default: spring. */
toggleSpring?: SpringConfig
/** Show a temporary bottom edge fade while height animation is running. */
edge?: boolean
/** Edge fade height in px. Default: 20. */
edgeHeight?: number
/** Edge fade opacity (0-1). Default: 1. */
edgeOpacity?: number
/** Delay before edge fades out after height settles. Default: 320. */
edgeIdle?: number
/** Edge fade-out duration in seconds. Default: 0.24. */
edgeFade?: number
/** Edge fade-in duration in seconds. Default: 0.2. */
edgeRise?: number
}
/**
* Wraps children in a container that animates from zero height on mount.
*
* Includes a ResizeObserver so content changes after mount are also spring-animated.
* Used for timeline turns, assistant part groups, and user messages.
*/
export function GrowBox(props: GrowBoxProps) {
const reduce = useReducedMotion()
const spring = () => props.spring ?? GROW_SPRING
const toggleSpring = () => props.toggleSpring ?? spring()
let mode: "mount" | "toggle" = "mount"
let root: HTMLDivElement | undefined
let body: HTMLDivElement | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let edgeRef: HTMLDivElement | undefined
let edgeAnim: AnimationPlaybackControls | undefined
let edgeTimer: ReturnType<typeof setTimeout> | undefined
let edgeOn = false
let mountFrame: number | undefined
let resizeFrame: number | undefined
let observer: ResizeObserver | undefined
let springTarget = -1
const height = tunableSpringValue<number>(0, {
type: "spring",
get visualDuration() {
return (mode === "toggle" ? toggleSpring() : spring()).visualDuration
},
get bounce() {
return (mode === "toggle" ? toggleSpring() : spring()).bounce
},
})
const gap = () => Math.max(0, props.gap ?? 0)
const grow = () => props.grow !== false
const watch = () => props.watch === true
const open = () => props.open !== false
const animateToggle = () => props.animateToggle !== false
const edge = () => props.edge === true
const edgeHeight = () => Math.max(0, props.edgeHeight ?? 20)
const edgeOpacity = () => Math.min(1, Math.max(0, props.edgeOpacity ?? 1))
const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320)
const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24)
const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2)
const animated = () => props.animate !== false && !reduce()
const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0
const stopEdgeTimer = () => {
if (edgeTimer === undefined) return
clearTimeout(edgeTimer)
edgeTimer = undefined
}
const hideEdge = (instant = false) => {
stopEdgeTimer()
if (!edgeRef) {
edgeOn = false
return
}
edgeAnim?.stop()
edgeAnim = undefined
if (instant || reduce()) {
edgeRef.style.opacity = "0"
edgeOn = false
return
}
if (!edgeOn) {
edgeRef.style.opacity = "0"
return
}
const current = animate(edgeRef, { opacity: 0 }, { type: "spring", visualDuration: edgeFade(), bounce: 0 })
edgeAnim = current
current.finished
.catch(() => {})
.finally(() => {
if (edgeAnim !== current) return
edgeAnim = undefined
if (!edgeRef) return
edgeRef.style.opacity = "0"
edgeOn = false
})
}
const showEdge = () => {
stopEdgeTimer()
if (!edgeRef) return
if (reduce()) {
edgeRef.style.opacity = `${edgeOpacity()}`
edgeOn = true
return
}
if (edgeOn && edgeAnim === undefined) {
edgeRef.style.opacity = `${edgeOpacity()}`
return
}
edgeAnim?.stop()
edgeAnim = undefined
if (!edgeOn) edgeRef.style.opacity = "0"
const current = animate(
edgeRef,
{ opacity: edgeOpacity() },
{ type: "spring", visualDuration: edgeRise(), bounce: 0 },
)
edgeAnim = current
edgeOn = true
current.finished
.catch(() => {})
.finally(() => {
if (edgeAnim !== current) return
edgeAnim = undefined
if (!edgeRef) return
edgeRef.style.opacity = `${edgeOpacity()}`
})
}
const queueEdgeHide = () => {
stopEdgeTimer()
if (!edgeOn) return
if (edgeIdle() <= 0) {
hideEdge()
return
}
edgeTimer = setTimeout(() => {
edgeTimer = undefined
hideEdge()
}, edgeIdle())
}
const hideBody = () => {
if (!body) return
body.style.opacity = "0"
body.style.filter = "blur(2px)"
}
const clearBody = () => {
if (!body) return
body.style.opacity = ""
body.style.filter = ""
}
const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => {
if (props.fade === false || !body) return
if (reduce()) {
clearBody()
return
}
hideBody()
fadeAnim?.stop()
fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring())
fadeAnim.finished.then(() => {
if (!body || !open()) return
clearBody()
})
}
const setInstant = (visible: boolean) => {
const next = visible ? targetHeight() : 0
springTarget = next
height.jump(next)
root!.style.height = visible ? "" : "0px"
root!.style.overflow = visible ? "" : "clip"
hideEdge(true)
if (visible || props.fade === false) clearBody()
else hideBody()
}
const currentHeight = () => {
if (!root) return 0
const v = root.style.height
if (v && v !== "auto") {
const n = Number.parseFloat(v)
if (!Number.isNaN(n)) return n
}
return Math.max(0, root.getBoundingClientRect().height)
}
const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0))
const setHeight = (nextMode: "mount" | "toggle" = "mount") => {
if (!root || !open()) return
const next = targetHeight()
if (reduce()) {
springTarget = next
height.jump(next)
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
root.style.height = "auto"
root.style.overflow = next > 0 ? "visible" : "clip"
return
}
if (next === springTarget) return
const prev = currentHeight()
if (Math.abs(next - prev) < 1) {
springTarget = next
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
}
return
}
root.style.overflow = "clip"
springTarget = next
mode = nextMode
height.set(next)
}
onMount(() => {
if (!root || !body) return
const offChange = height.on("change", (next) => {
if (!root) return
root.style.height = `${Math.max(0, next)}px`
})
const offStart = height.on("animationStart", () => {
if (!root) return
root.style.overflow = "clip"
root.style.willChange = "height"
root.style.contain = "layout style"
if (edgeReady()) showEdge()
})
const offComplete = height.on("animationComplete", () => {
if (!root) return
root.style.willChange = ""
root.style.contain = ""
if (!open()) {
springTarget = 0
root.style.height = "0px"
root.style.overflow = "clip"
return
}
const next = targetHeight()
springTarget = next
if (props.autoHeight === false || watch()) {
root.style.height = `${next}px`
root.style.overflow = next > 0 ? "visible" : "clip"
if (edgeReady()) queueEdgeHide()
return
}
root.style.height = "auto"
root.style.overflow = "visible"
if (edgeReady()) queueEdgeHide()
})
onCleanup(() => {
offComplete()
offStart()
offChange()
})
if (watch()) {
observer = new ResizeObserver(() => {
if (!open()) return
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
setHeight("mount")
})
})
observer.observe(body)
}
if (!animated()) {
setInstant(open())
return
}
if (props.fade !== false) hideBody()
hideEdge(true)
if (!open()) {
root.style.height = "0px"
root.style.overflow = "clip"
} else {
if (grow()) {
root.style.height = "0px"
root.style.overflow = "clip"
} else {
root.style.height = "auto"
root.style.overflow = "visible"
}
mountFrame = requestAnimationFrame(() => {
mountFrame = undefined
fadeBodyIn("mount")
if (grow()) setHeight("mount")
})
}
})
createEffect(
on(
() => props.open,
(value) => {
if (value === undefined) return
if (!root || !body) return
if (!animateToggle() || reduce()) {
setInstant(value)
return
}
fadeAnim?.stop()
if (!value) hideEdge(true)
if (!value) {
const next = currentHeight()
if (Math.abs(next - height.get()) >= 1) {
springTarget = next
height.jump(next)
root.style.height = `${next}px`
}
if (props.fade !== false) {
fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
}
root.style.overflow = "clip"
springTarget = 0
mode = "toggle"
height.set(0)
return
}
fadeBodyIn("toggle")
setHeight("toggle")
},
{ defer: true },
),
)
createEffect(() => {
if (!edgeRef) return
edgeRef.style.height = `${edgeHeight()}px`
if (!animated() || !open() || edgeHeight() <= 0) {
hideEdge(true)
return
}
if (edge()) return
hideEdge()
})
createEffect(() => {
if (!root || !body) return
if (!reduce()) return
fadeAnim?.stop()
edgeAnim?.stop()
setInstant(open())
})
onCleanup(() => {
stopEdgeTimer()
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
observer?.disconnect()
height.destroy()
fadeAnim?.stop()
edgeAnim?.stop()
edgeAnim = undefined
edgeOn = false
})
return (
<div
ref={root}
data-slot={props.slot}
class={props.class}
style={{
transform: "translateZ(0)",
position: "relative",
height: open() ? undefined : "0px",
overflow: open() ? undefined : "clip",
}}
>
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
{props.children}
</div>
<div
ref={edgeRef}
data-slot="grow-box-edge"
style={{
position: "absolute",
left: "0",
right: "0",
bottom: "0",
height: `${edgeHeight()}px`,
opacity: 0,
"pointer-events": "none",
background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
}}
/>
</div>
)
}
@@ -244,6 +244,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
event.stopPropagation()
if (e.key === "Escape") {
event.preventDefault()
e.currentTarget.blur()
split.onCancel()
return
}
+89 -450
View File
@@ -1,20 +1,10 @@
[data-component="assistant-message"] {
content-visibility: auto;
width: 100%;
}
[data-component="assistant-parts"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
}
[data-component="assistant-part-item"] {
width: 100%;
min-width: 0;
gap: 12px;
}
[data-component="user-message"] {
@@ -37,14 +27,6 @@
color: var(--text-weak);
}
[data-slot="user-message-inner"] {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
gap: 4px;
}
[data-slot="user-message-attachments"] {
display: flex;
flex-wrap: wrap;
@@ -53,7 +35,6 @@
width: fit-content;
max-width: min(82%, 64ch);
margin-left: auto;
margin-bottom: 4px;
}
[data-slot="user-message-attachment"] {
@@ -153,7 +134,7 @@
[data-slot="user-message-copy-wrapper"] {
min-height: 24px;
margin-top: 0;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-end;
@@ -163,6 +144,7 @@
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
@@ -205,21 +187,56 @@
opacity: 1;
pointer-events: auto;
}
.text-text-strong {
color: var(--text-strong);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
}
[data-component="text-part"] {
width: 100%;
margin-top: 0;
padding-block: 4px;
position: relative;
margin-top: 24px;
[data-slot="text-part-body"] {
margin-top: 0;
}
[data-slot="text-part-turn-summary"] {
[data-slot="text-part-copy-wrapper"] {
min-height: 24px;
margin-top: 4px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
will-change: opacity;
[data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
}
[data-slot="text-part-meta"] {
user-select: none;
}
[data-slot="text-part-copy-wrapper"][data-interrupted] {
width: 100%;
min-width: 0;
justify-content: flex-end;
gap: 12px;
}
&:hover [data-slot="text-part-copy-wrapper"],
&:focus-within [data-slot="text-part-copy-wrapper"] {
opacity: 1;
pointer-events: auto;
}
[data-component="markdown"] {
@@ -228,10 +245,6 @@
}
}
[data-component="assistant-part-item"][data-kind="text"][data-last="true"] [data-component="text-part"] {
padding-bottom: 0;
}
[data-component="compaction-part"] {
width: 100%;
display: flex;
@@ -265,6 +278,7 @@
line-height: var(--line-height-normal);
[data-component="markdown"] {
margin-top: 24px;
font-style: normal;
font-size: inherit;
color: var(--text-weak);
@@ -358,16 +372,13 @@
height: auto;
max-height: 240px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
mask-image: linear-gradient(to bottom, transparent 0, black 6px, black calc(100% - 6px), transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
&::-webkit-scrollbar {
display: none;
}
[data-component="markdown"] {
overflow: visible;
}
@@ -437,7 +448,7 @@
[data-component="write-trigger"] {
display: flex;
align-items: center;
justify-content: flex-start;
justify-content: space-between;
gap: 8px;
width: 100%;
@@ -450,8 +461,7 @@
}
[data-slot="message-part-title"] {
flex-shrink: 1;
min-width: 0;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
@@ -483,45 +493,40 @@
[data-slot="message-part-title-text"] {
text-transform: capitalize;
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-meta-line"],
.message-part-meta-line {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: var(--font-weight-regular);
[data-component="diff-changes"] {
flex-shrink: 0;
gap: 6px;
}
}
.message-part-meta-line.soft {
[data-slot="message-part-title-filename"] {
color: var(--text-base);
}
}
[data-slot="message-part-title-filename"] {
/* No text-transform - preserve original filename casing */
color: var(--text-strong);
flex-shrink: 0;
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory-inline"] {
color: var(--text-weak);
[data-slot="message-part-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
max-width: min(48vw, 36ch);
font-weight: var(--font-weight-regular);
}
[data-slot="message-part-directory"] {
color: var(--text-weak);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
direction: rtl;
text-align: left;
}
[data-slot="message-part-filename"] {
color: var(--text-strong);
flex-shrink: 0;
}
[data-slot="message-part-actions"] {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
}
}
[data-component="edit-content"] {
@@ -612,17 +617,6 @@
}
}
[data-slot="webfetch-meta"] {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
[data-component="tool-action"] {
flex-shrink: 0;
}
}
[data-component="todos"] {
padding: 10px 0 24px 0;
display: flex;
@@ -645,6 +639,7 @@
}
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
display: flex;
align-items: center;
@@ -652,352 +647,28 @@
gap: 0px;
cursor: pointer;
&[data-pending] {
cursor: default;
}
[data-slot="context-tool-group-title"] {
flex-shrink: 1;
min-width: 0;
}
}
/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
width: auto;
flex: 0 1 auto;
[data-slot="basic-tool-tool-info"] {
flex: 0 1 auto;
}
}
[data-component="context-tool-step"] {
width: 100%;
min-width: 0;
padding-left: 12px;
}
[data-component="context-tool-expanded-list"] {
display: flex;
flex-direction: column;
padding: 4px 0 4px 12px;
max-height: 200px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
&::-webkit-scrollbar {
display: none;
}
}
[data-component="context-tool-expanded-row"] {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
height: 22px;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
[data-slot="context-tool-expanded-action"] {
flex-shrink: 0;
font-size: var(--font-size-base);
font-weight: 500;
color: var(--text-base);
}
[data-slot="context-tool-expanded-detail"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-base);
color: var(--text-base);
opacity: 0.75;
}
}
[data-component="context-tool-rolling-row"] {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
white-space: nowrap;
overflow: hidden;
padding-left: 12px;
[data-slot="context-tool-rolling-action"] {
flex-shrink: 0;
font-size: var(--font-size-base);
font-weight: 500;
color: var(--text-base);
}
[data-slot="context-tool-rolling-detail"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--font-size-base);
color: var(--text-weak);
}
}
[data-component="shell-rolling-results"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
[data-slot="shell-rolling-header-clip"] {
&:hover [data-slot="shell-rolling-actions"] {
opacity: 1;
}
&[data-clickable="true"] {
cursor: pointer;
}
}
[data-slot="shell-rolling-header"] {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
height: 37px;
box-sizing: border-box;
}
[data-slot="shell-rolling-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: 14px;
font-style: normal;
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
}
[data-slot="shell-rolling-subtitle"] {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: 14px;
font-weight: var(--font-weight-normal);
line-height: var(--line-height-large);
color: var(--text-weak);
}
[data-slot="shell-rolling-actions"] {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.shell-rolling-copy {
border: none !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
[data-slot="icon-svg"] {
color: var(--icon-weaker);
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
border-radius: var(--radius-sm);
[data-slot="icon-svg"] {
color: var(--icon-base);
}
}
}
[data-slot="shell-rolling-arrow"] {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--icon-weaker);
transform: rotate(-90deg);
transition: transform 0.15s ease;
}
[data-slot="shell-rolling-arrow"][data-open="true"] {
transform: rotate(0deg);
}
}
[data-component="shell-rolling-output"] {
width: 100%;
min-width: 0;
}
[data-slot="shell-rolling-preview"] {
width: 100%;
min-width: 0;
}
[data-component="shell-expanded-output"] {
width: 100%;
max-width: 100%;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
[data-component="shell-expanded-shell"] {
position: relative;
width: 100%;
min-width: 0;
border: 1px solid var(--border-weak-base);
border-radius: 6px;
background: transparent;
overflow: hidden;
}
[data-slot="shell-expanded-body"] {
position: relative;
width: 100%;
min-width: 0;
}
[data-slot="shell-expanded-top"] {
position: relative;
width: 100%;
min-width: 0;
padding: 9px 44px 9px 16px;
box-sizing: border-box;
}
[data-slot="shell-expanded-command"] {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
min-width: 0;
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 13px;
line-height: 1.45;
}
[data-slot="shell-expanded-prompt"] {
flex-shrink: 0;
color: var(--text-weaker);
}
[data-slot="shell-expanded-input"] {
min-width: 0;
color: var(--text-strong);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
[data-slot="shell-expanded-actions"] {
position: absolute;
top: 50%;
right: 8px;
z-index: 1;
transform: translateY(-50%);
}
.shell-expanded-copy {
border: none !important;
outline: none !important;
box-shadow: none !important;
background: transparent !important;
[data-slot="icon-svg"] {
[data-slot="collapsible-arrow"] {
color: var(--icon-weaker);
}
}
&:hover:not(:disabled) {
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
border-radius: var(--radius-sm);
[data-component="context-tool-group-list"] {
padding: 6px 0 4px 0;
display: flex;
flex-direction: column;
gap: 2px;
[data-slot="icon-svg"] {
color: var(--icon-base);
}
[data-slot="context-tool-group-item"] {
min-width: 0;
padding: 6px 0;
}
}
[data-slot="shell-expanded-divider"] {
width: 100%;
height: 1px;
background: var(--border-weak-base);
}
[data-slot="shell-expanded-pre"] {
margin: 0;
padding: 12px 16px;
white-space: pre-wrap;
overflow-wrap: anywhere;
code {
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: 13px;
line-height: 1.45;
color: var(--text-base);
}
}
[data-component="shell-rolling-command"],
[data-component="shell-rolling-row"] {
display: inline-flex;
align-items: center;
width: 100%;
min-width: 0;
overflow: hidden;
white-space: pre;
padding-left: 12px;
}
[data-slot="shell-rolling-text"] {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-family-mono);
font-feature-settings: var(--font-family-mono--font-feature-settings);
font-size: var(--font-size-small);
line-height: var(--line-height-large);
}
[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
color: var(--text-base);
}
[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
color: var(--text-weaker);
}
[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
color: var(--text-weak);
}
[data-component="diagnostics"] {
display: flex;
flex-direction: column;
@@ -1058,30 +729,6 @@
width: 100%;
}
[data-slot="assistant-part-grow"] {
width: 100%;
min-width: 0;
overflow: visible;
}
[data-component="tool-part-wrapper"][data-tool="bash"] {
[data-component="tool-trigger"] {
width: auto;
max-width: 100%;
}
[data-slot="basic-tool-tool-info-main"] {
align-items: center;
}
[data-slot="basic-tool-tool-title"],
[data-slot="basic-tool-tool-subtitle"] {
display: inline-flex;
align-items: center;
line-height: var(--line-height-large);
}
}
[data-component="dock-prompt"][data-kind="permission"] {
position: relative;
display: flex;
@@ -1540,7 +1187,8 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
height: 37px;
height: 40px;
padding-bottom: 8px;
background-color: var(--background-stronger);
}
}
@@ -1551,12 +1199,11 @@
}
[data-slot="apply-patch-trigger-content"] {
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-start;
max-width: 100%;
min-width: 0;
gap: 8px;
justify-content: space-between;
width: 100%;
gap: 20px;
}
[data-slot="apply-patch-file-info"] {
@@ -1590,9 +1237,9 @@
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
gap: 8px;
gap: 16px;
align-items: center;
justify-content: flex-start;
justify-content: flex-end;
}
[data-slot="apply-patch-change"] {
@@ -1632,11 +1279,10 @@
}
[data-component="tool-loaded-file"] {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0 4px 12px;
padding: 4px 0 4px 28px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-regular);
@@ -1647,11 +1293,4 @@
flex-shrink: 0;
color: var(--icon-weak);
}
span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
File diff suppressed because it is too large Load Diff
+7 -25
View File
@@ -1,9 +1,8 @@
import { attachSpring, motionValue } from "motion"
import type { SpringOptions } from "motion"
import { createEffect, createSignal, onCleanup } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
type Opt = Partial<Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">>
const eq = (a: Opt | undefined, b: Opt | undefined) =>
a?.visualDuration === b?.visualDuration &&
a?.bounce === b?.bounce &&
@@ -14,41 +13,24 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
const read = () => (typeof options === "function" ? options() : options)
const reduce = useReducedMotion()
const [value, setValue] = createSignal(target())
const source = motionValue(value())
const spring = motionValue(value())
let config = read()
let reduced = reduce()
let stop = reduced ? () => {} : attachSpring(spring, source, config)
let off = spring.on("change", (next) => setValue(next))
let stop = attachSpring(spring, source, config)
let off = spring.on("change", (next: number) => setValue(next))
createEffect(() => {
const next = target()
if (reduced) {
source.set(next)
spring.set(next)
setValue(next)
return
}
source.set(next)
source.set(target())
})
createEffect(() => {
if (!options) return
const next = read()
const skip = reduce()
if (eq(config, next) && reduced === skip) return
if (eq(config, next)) return
config = next
reduced = skip
stop()
stop = skip ? () => {} : attachSpring(spring, source, next)
if (skip) {
const value = target()
source.set(value)
spring.set(value)
setValue(value)
return
}
stop = attachSpring(spring, source, next)
setValue(spring.get())
})
-77
View File
@@ -1,77 +0,0 @@
import { followValue } from "motion"
import type { MotionValue } from "motion"
export { animate, springValue } from "motion"
export type { AnimationPlaybackControls } from "motion"
/**
* Like `springValue` but preserves getters on the config object.
* `springValue` spreads config at creation, snapshotting getter values.
* This passes the config through to `followValue` intact, so getters
* on `visualDuration` etc. fire on every `.set()` call.
*/
export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
return followValue(initial, config as any)
}
let _growDuration = 0.5
let _collapsibleDuration = 0.3
export const GROW_SPRING = {
type: "spring" as const,
get visualDuration() {
return _growDuration
},
bounce: 0,
}
export const COLLAPSIBLE_SPRING = {
type: "spring" as const,
get visualDuration() {
return _collapsibleDuration
},
bounce: 0,
}
export const setGrowDuration = (v: number) => {
_growDuration = v
}
export const setCollapsibleDuration = (v: number) => {
_collapsibleDuration = v
}
export const getGrowDuration = () => _growDuration
export const getCollapsibleDuration = () => _collapsibleDuration
export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
export const FAST_SPRING = {
type: "spring" as const,
visualDuration: 0.35,
bounce: 0,
}
export const GLOW_SPRING = {
type: "spring" as const,
visualDuration: 0.4,
bounce: 0.15,
}
export const WIPE_MASK =
"linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
export const clearMaskStyles = (el: HTMLElement) => {
el.style.maskImage = ""
el.style.webkitMaskImage = ""
el.style.maskSize = ""
el.style.webkitMaskSize = ""
el.style.maskRepeat = ""
el.style.webkitMaskRepeat = ""
el.style.maskPosition = ""
el.style.webkitMaskPosition = ""
}
export const clearFadeStyles = (el: HTMLElement) => {
el.style.opacity = ""
el.style.filter = ""
el.style.transform = ""
}
@@ -1,92 +0,0 @@
[data-component="rolling-results"] {
--rolling-results-row-height: 22px;
--rolling-results-fixed-height: var(--rolling-results-row-height);
--rolling-results-fixed-gap: 0px;
--rolling-results-row-gap: 0px;
display: block;
width: 100%;
min-width: 0;
[data-slot="rolling-results-viewport"] {
position: relative;
min-width: 0;
height: 0;
overflow: clip;
}
&[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
mask-image: linear-gradient(
to bottom,
transparent 0%,
black var(--rolling-results-fade),
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
black var(--rolling-results-fade),
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
transparent 100%
);
}
[data-slot="rolling-results-fixed"] {
min-width: 0;
height: var(--rolling-results-fixed-height);
min-height: var(--rolling-results-fixed-height);
display: flex;
align-items: center;
}
[data-slot="rolling-results-window"] {
min-width: 0;
margin-top: var(--rolling-results-fixed-gap);
height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
overflow: clip;
}
&[data-scrollable="true"] [data-slot="rolling-results-window"] {
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
&[data-scrollable="true"] [data-slot="rolling-results-track"] {
transform: none !important;
will-change: auto;
}
[data-slot="rolling-results-body"] {
min-width: 0;
}
[data-slot="rolling-results-track"] {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--rolling-results-row-gap);
will-change: transform;
}
[data-slot="rolling-results-row"],
[data-slot="rolling-results-empty"] {
min-width: 0;
height: var(--rolling-results-row-height);
min-height: var(--rolling-results-row-height);
display: flex;
align-items: center;
}
[data-slot="rolling-results-row"] {
color: var(--text-base);
}
[data-slot="rolling-results-empty"] {
color: var(--text-weaker);
}
}
@@ -1,325 +0,0 @@
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
export type RollingResultsProps<T> = {
items: T[]
render: (item: T, index: number) => JSX.Element
fixed?: JSX.Element
getKey?: (item: T, index: number) => string
rows?: number
rowHeight?: number
fixedHeight?: number
rowGap?: number
open?: boolean
scrollable?: boolean
spring?: SpringConfig
animate?: boolean
class?: string
empty?: JSX.Element
noFadeOnCollapse?: boolean
}
export function RollingResults<T>(props: RollingResultsProps<T>) {
let view: HTMLDivElement | undefined
let track: HTMLDivElement | undefined
let windowEl: HTMLDivElement | undefined
let shift: AnimationPlaybackControls | undefined
let resize: AnimationPlaybackControls | undefined
let edgeFade: AnimationPlaybackControls | undefined
const reduce = useReducedMotion()
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
const fixedHeight = createMemo(() => Math.max(0, Math.round(props.fixedHeight ?? rowHeight())))
const rowGap = createMemo(() => Math.max(0, Math.round(props.rowGap ?? 0)))
const fixed = createMemo(() => props.fixed !== undefined)
const list = createMemo(() => props.items ?? [])
const count = createMemo(() => list().length)
// scrollReady is the internal "transition complete" state.
// It only becomes true after props.scrollable is true AND the offset animation has settled.
const [scrollReady, setScrollReady] = createSignal(false)
const backstop = createMemo(() => Math.max(rows() * 2, 12))
const rendered = createMemo(() => {
const items = list()
if (scrollReady()) return items
const max = backstop()
return items.length > max ? items.slice(-max) : items
})
const skipped = createMemo(() => {
if (scrollReady()) return 0
return count() - rendered().length
})
const open = createMemo(() => props.open !== false)
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
const noFade = () => props.noFadeOnCollapse === true
const overflowing = createMemo(() => count() > rows())
const shown = createMemo(() => Math.min(rows(), count()))
const step = createMemo(() => rowHeight() + rowGap())
const offset = createMemo(() => Math.max(0, count() - shown()) * step())
const body = createMemo(() => {
if (shown() > 0) {
return shown() * rowHeight() + Math.max(0, shown() - 1) * rowGap()
}
if (props.empty === undefined) return 0
return rowHeight()
})
const gap = createMemo(() => {
if (!fixed()) return 0
if (body() <= 0) return 0
return rowGap()
})
const height = createMemo(() => {
if (!open()) return 0
if (!fixed()) return body()
return fixedHeight() + gap() + body()
})
const key = (item: T, index: number) => {
const value = props.getKey
if (value) return value(item, index)
return String(index)
}
const setTrack = (value: number) => {
if (!track) return
track.style.transform = `translateY(${-Math.round(value)}px)`
}
const setView = (value: number) => {
if (!view) return
view.style.height = `${Math.max(0, Math.round(value))}px`
}
onMount(() => {
setTrack(offset())
})
// Original WAAPI offset animation — untouched rolling behavior.
createEffect(
on(
offset,
(next) => {
if (!track) return
if (scrollReady()) return
if (props.scrollable) return
if (!active()) {
shift?.stop()
shift = undefined
setTrack(next)
return
}
shift?.stop()
const anim = animate(track, { transform: `translateY(${-next}px)` }, props.spring ?? GROW_SPRING)
shift = anim
anim.finished
.catch(() => {})
.finally(() => {
if (shift !== anim) return
setTrack(next)
shift = undefined
})
},
{ defer: true },
),
)
// Scrollable transition: wait for the offset animation to finish,
// then batch all DOM changes in one synchronous pass.
createEffect(
on(
() => props.scrollable === true,
(isScrollable) => {
if (!isScrollable) {
setScrollReady(false)
if (windowEl) {
windowEl.style.overflowY = ""
windowEl.style.maskImage = ""
windowEl.style.webkitMaskImage = ""
}
return
}
// Wait for the current offset animation to settle (if any).
const done = shift?.finished ?? Promise.resolve()
done
.catch(() => {})
.then(() => {
if (props.scrollable !== true) return
// Batch the signal update — Solid updates the DOM synchronously:
// rendered() returns all items, skipped() returns 0, padding-top removed,
// data-scrollable becomes "true".
batch(() => setScrollReady(true))
// Now the DOM has all items. Safe to switch layout strategy.
// CSS handles `transform: none !important` on [data-scrollable="true"].
if (windowEl) {
windowEl.style.overflowY = "auto"
windowEl.scrollTop = windowEl.scrollHeight
}
updateScrollMask()
})
},
),
)
// Auto-scroll to bottom when new items arrive in scrollable mode
const [userScrolled, setUserScrolled] = createSignal(false)
const updateScrollMask = () => {
if (!windowEl) return
if (!scrollReady()) {
windowEl.style.maskImage = ""
windowEl.style.webkitMaskImage = ""
return
}
const { scrollTop, scrollHeight, clientHeight } = windowEl
const atBottom = scrollHeight - scrollTop - clientHeight < 8
// Top fade is always present in scrollable mode (matches rolling mode appearance).
// Bottom fade only when not scrolled to the end.
const mask = atBottom
? "linear-gradient(to bottom, transparent 0, black 8px)"
: "linear-gradient(to bottom, transparent 0, black 8px, black calc(100% - 8px), transparent 100%)"
windowEl.style.maskImage = mask
windowEl.style.webkitMaskImage = mask
}
createEffect(() => {
if (!scrollReady()) {
setUserScrolled(false)
return
}
const _n = count()
const scrolled = userScrolled()
if (scrolled) return
if (windowEl) {
windowEl.scrollTop = windowEl.scrollHeight
updateScrollMask()
}
})
const onWindowScroll = () => {
if (!windowEl || !scrollReady()) return
const atBottom = windowEl.scrollHeight - windowEl.scrollTop - windowEl.clientHeight < 8
setUserScrolled(!atBottom)
updateScrollMask()
}
const EDGE_MASK = "linear-gradient(to top, transparent 0%, black 8px)"
const applyEdge = () => {
if (!view) return
edgeFade?.stop()
edgeFade = undefined
view.style.maskImage = EDGE_MASK
view.style.webkitMaskImage = EDGE_MASK
view.style.maskSize = "100% 100%"
view.style.maskRepeat = "no-repeat"
}
const clearEdge = () => {
if (!view) return
if (!active()) {
clearMaskStyles(view)
return
}
edgeFade?.stop()
const anim = animate(view, { maskSize: "100% 200%" }, props.spring ?? GROW_SPRING)
edgeFade = anim
anim.finished
.catch(() => {})
.then(() => {
if (edgeFade !== anim || !view) return
clearMaskStyles(view)
edgeFade = undefined
})
}
createEffect(
on(height, (next, prev) => {
if (!view) return
if (!active()) {
resize?.stop()
resize = undefined
setView(next)
view.style.opacity = ""
clearEdge()
return
}
const collapsing = next === 0 && prev !== undefined && prev > 0
const expanding = prev === 0 && next > 0
resize?.stop()
view.style.opacity = ""
applyEdge()
const spring = props.spring ?? GROW_SPRING
const anim = collapsing
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: 0 }, spring)
: expanding
? animate(view, noFade() ? { height: `${next}px` } : { height: `${next}px`, opacity: [0, 1] }, spring)
: animate(view, { height: `${next}px` }, spring)
resize = anim
anim.finished
.catch(() => {})
.finally(() => {
view.style.opacity = ""
if (resize !== anim) return
setView(next)
resize = undefined
clearEdge()
})
}),
)
onCleanup(() => {
shift?.stop()
resize?.stop()
edgeFade?.stop()
shift = undefined
resize = undefined
edgeFade = undefined
})
return (
<div
data-component="rolling-results"
class={props.class}
data-open={open() ? "true" : "false"}
data-overflowing={overflowing() ? "true" : "false"}
data-scrollable={scrollReady() ? "true" : "false"}
data-fixed={fixed() ? "true" : "false"}
style={{
"--rolling-results-row-height": `${rowHeight()}px`,
"--rolling-results-fixed-height": `${fixed() ? fixedHeight() : 0}px`,
"--rolling-results-fixed-gap": `${gap()}px`,
"--rolling-results-row-gap": `${rowGap()}px`,
"--rolling-results-fade": `${Math.round(rowHeight() * 0.6)}px`,
}}
>
<div ref={view} data-slot="rolling-results-viewport" aria-live="polite">
<Show when={fixed()}>
<div data-slot="rolling-results-fixed">{props.fixed}</div>
</Show>
<div ref={windowEl} data-slot="rolling-results-window" onScroll={onWindowScroll}>
<div data-slot="rolling-results-body">
<Show when={list().length === 0 && props.empty !== undefined}>
<div data-slot="rolling-results-empty">{props.empty}</div>
</Show>
<div
ref={track}
data-slot="rolling-results-track"
style={{ "padding-top": scrollReady() ? undefined : `${skipped() * step()}px` }}
>
<For each={rendered()}>
{(item, index) => (
<div data-slot="rolling-results-row" data-key={key(item, index())}>
{props.render(item, index())}
</div>
)}
</For>
</div>
</div>
</div>
</div>
</div>
)
}
+12 -7
View File
@@ -9,13 +9,6 @@
overflow-y: auto;
scrollbar-width: none;
outline: none;
display: block;
overflow-anchor: none;
}
.scroll-view__viewport[data-reverse="true"] {
display: flex;
flex-direction: column-reverse;
}
.scroll-view__viewport::-webkit-scrollbar {
@@ -52,6 +45,18 @@
background-color: var(--border-strong-base);
}
.dark .scroll-view__thumb::after,
[data-theme="dark"] .scroll-view__thumb::after {
background-color: var(--border-weak-base);
}
.dark .scroll-view__thumb:hover::after,
[data-theme="dark"] .scroll-view__thumb:hover::after,
.dark .scroll-view__thumb[data-dragging="true"]::after,
[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after {
background-color: var(--border-strong-base);
}
.scroll-view__thumb[data-visible="true"] {
opacity: 1;
}
+14 -56
View File
@@ -1,18 +1,17 @@
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show } from "solid-js"
import { animate, type AnimationPlaybackControls } from "motion"
import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
import { useI18n } from "../context/i18n"
import { FAST_SPRING } from "./motion"
export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void
reverse?: boolean
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
}
export function ScrollView(props: ScrollViewProps) {
const i18n = useI18n()
const merged = mergeProps({ orientation: "vertical" }, props)
const [local, events, rest] = splitProps(
props,
["class", "children", "viewportRef", "style", "reverse"],
merged,
["class", "children", "viewportRef", "orientation", "style"],
[
"onScroll",
"onWheel",
@@ -26,9 +25,9 @@ export function ScrollView(props: ScrollViewProps) {
],
)
let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
let anim: AnimationPlaybackControls | undefined
const [isHovered, setIsHovered] = createSignal(false)
const [isDragging, setIsDragging] = createSignal(false)
@@ -37,8 +36,6 @@ export function ScrollView(props: ScrollViewProps) {
const [thumbTop, setThumbTop] = createSignal(0)
const [showThumb, setShowThumb] = createSignal(false)
const reverse = () => local.reverse === true
const updateThumb = () => {
if (!viewportRef) return
const { scrollTop, scrollHeight, clientHeight } = viewportRef
@@ -60,13 +57,9 @@ export function ScrollView(props: ScrollViewProps) {
const maxScrollTop = scrollHeight - clientHeight
const maxThumbTop = trackHeight - height
const top = (() => {
if (maxScrollTop <= 0) return 0
if (!reverse()) return (scrollTop / maxScrollTop) * maxThumbTop
return ((maxScrollTop + scrollTop) / maxScrollTop) * maxThumbTop
})()
const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
// Ensure thumb stays within bounds
// Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
setThumbHeight(height)
@@ -89,7 +82,6 @@ export function ScrollView(props: ScrollViewProps) {
}
onCleanup(() => {
stop()
observer.disconnect()
})
@@ -131,31 +123,6 @@ export function ScrollView(props: ScrollViewProps) {
thumbRef.addEventListener("pointerup", onPointerUp)
}
const stop = () => {
if (!anim) return
anim.stop()
anim = undefined
}
const limit = (top: number) => {
const max = viewportRef.scrollHeight - viewportRef.clientHeight
if (reverse()) return Math.max(-max, Math.min(0, top))
return Math.max(0, Math.min(max, top))
}
const glide = (top: number) => {
stop()
anim = animate(viewportRef.scrollTop, limit(top), {
...FAST_SPRING,
onUpdate: (v) => {
viewportRef.scrollTop = v
},
onComplete: () => {
anim = undefined
},
})
}
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
// We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -180,11 +147,11 @@ export function ScrollView(props: ScrollViewProps) {
break
case "Home":
e.preventDefault()
glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0)
viewportRef.scrollTo({ top: 0, behavior: "smooth" })
break
case "End":
e.preventDefault()
glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight)
viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
break
case "ArrowUp":
e.preventDefault()
@@ -199,6 +166,7 @@ export function ScrollView(props: ScrollViewProps) {
return (
<div
ref={rootRef}
class={`scroll-view ${local.class || ""}`}
style={local.style}
onPointerEnter={() => setIsHovered(true)}
@@ -209,26 +177,16 @@ export function ScrollView(props: ScrollViewProps) {
<div
ref={viewportRef}
class="scroll-view__viewport"
data-reverse={reverse() ? "true" : undefined}
onScroll={(e) => {
updateThumb()
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
onWheel={(e) => {
if (e.deltaY) stop()
if (typeof events.onWheel === "function") events.onWheel(e as any)
}}
onTouchStart={(e) => {
stop()
if (typeof events.onTouchStart === "function") events.onTouchStart(e as any)
}}
onWheel={events.onWheel as any}
onTouchStart={events.onTouchStart as any}
onTouchMove={events.onTouchMove as any}
onTouchEnd={events.onTouchEnd as any}
onTouchCancel={events.onTouchCancel as any}
onPointerDown={(e) => {
stop()
if (typeof events.onPointerDown === "function") events.onPointerDown(e as any)
}}
onPointerDown={events.onPointerDown as any}
onClick={events.onClick as any}
tabIndex={0}
role="region"
+11 -116
View File
@@ -1,4 +1,5 @@
[data-component="session-turn"] {
--sticky-header-height: calc(var(--session-title-height, 0px) + 24px);
height: 100%;
min-height: 0;
min-width: 0;
@@ -25,7 +26,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 0px;
gap: 18px;
overflow-anchor: none;
}
@@ -42,127 +43,30 @@
align-self: stretch;
}
[data-slot="session-turn-assistant-lane"] {
width: 100%;
min-width: 0;
display: flex;
flex-direction: column;
align-self: stretch;
}
[data-slot="session-turn-thinking"] {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
white-space: nowrap;
color: var(--text-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
height: 36px;
line-height: 20px;
min-height: 20px;
[data-component="spinner"] {
width: 16px;
height: 16px;
}
> [data-component="text-shimmer"] {
flex: 0 0 auto;
white-space: nowrap;
}
}
[data-slot="session-turn-handoff-wrap"] {
width: 100%;
min-width: 0;
overflow: visible;
}
[data-slot="session-turn-handoff"] {
width: 100%;
min-width: 0;
min-height: 37px;
position: relative;
}
[data-slot="session-turn-thinking"] {
position: absolute;
inset: 0;
will-change: opacity, filter;
transition:
opacity 180ms ease-out,
filter 180ms ease-out,
transform 180ms ease-out;
}
[data-slot="session-turn-thinking"][data-visible="false"] {
opacity: 0;
filter: blur(2px);
transform: translateY(1px);
pointer-events: none;
}
[data-slot="session-turn-thinking"][data-visible="true"] {
opacity: 1;
filter: blur(0px);
transform: translateY(0px);
}
[data-slot="session-turn-meta"] {
position: absolute;
inset: 0;
min-height: 37px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
[data-slot="session-turn-meta"][data-interrupted] {
gap: 12px;
}
[data-slot="session-turn-meta"] [data-component="tooltip-trigger"] {
display: inline-flex;
width: fit-content;
}
[data-slot="session-turn-message-container"]:hover [data-slot="session-turn-meta"][data-visible="true"],
[data-slot="session-turn-message-container"]:focus-within [data-slot="session-turn-meta"][data-visible="true"] {
opacity: 1;
pointer-events: auto;
}
[data-slot="session-turn-meta-label"] {
user-select: none;
min-width: 0;
overflow: clip;
white-space: nowrap;
text-overflow: ellipsis;
}
[data-component="text-reveal"].session-turn-thinking-heading {
flex: 1 1 auto;
min-width: 0;
overflow: clip;
white-space: nowrap;
line-height: inherit;
color: var(--text-weaker);
font-weight: var(--font-weight-regular);
[data-slot="text-reveal-track"],
[data-slot="text-reveal-entering"],
[data-slot="text-reveal-leaving"] {
min-height: 0;
line-height: inherit;
}
}
.error-card {
@@ -180,7 +84,7 @@
display: flex;
flex-direction: column;
align-self: stretch;
gap: 0px;
gap: 12px;
> :first-child > [data-component="markdown"]:first-child {
margin-top: 0;
@@ -205,7 +109,6 @@
[data-component="session-turn-diffs-trigger"] {
width: 100%;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
@@ -215,7 +118,7 @@
[data-slot="session-turn-diffs-title"] {
display: inline-flex;
align-items: center;
align-items: baseline;
gap: 8px;
}
@@ -233,7 +136,7 @@
font-variant-numeric: tabular-nums;
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
line-height: var(--line-height-x-large);
}
[data-slot="session-turn-diffs-meta"] {
@@ -269,10 +172,8 @@
[data-slot="session-turn-diff-path"] {
display: flex;
flex-grow: 1;
min-width: 0;
align-items: baseline;
overflow: clip;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
@@ -280,22 +181,16 @@
}
[data-slot="session-turn-diff-directory"] {
flex: 1 1 auto;
color: var(--text-weak);
min-width: 0;
overflow: clip;
color: var(--text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
unicode-bidi: plaintext;
text-align: left;
}
[data-slot="session-turn-diff-filename"] {
flex-shrink: 0;
max-width: 100%;
min-width: 0;
overflow: clip;
white-space: nowrap;
color: var(--text-strong);
font-weight: var(--font-weight-medium);
}
+206 -348
View File
@@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
import { same } from "@opencode-ai/util/array"
import { Binary } from "@opencode-ai/util/binary"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
import { Dynamic } from "solid-js/web"
import { GrowBox } from "./grow-box"
import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
import { Card } from "./card"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { TextReveal } from "./text-reveal"
import { list } from "./text-utils"
import { SessionRetry } from "./session-retry"
import { Tooltip } from "./tooltip"
import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
@@ -77,12 +73,18 @@ function unwrap(message: string) {
return message
}
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
}
const hidden = new Set(["todowrite", "todoread"])
const emptyMessages: MessageType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const idle: SessionStatus = { type: "idle" as const }
const handoffHoldMs = 120
function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
@@ -139,7 +141,6 @@ export function SessionTurn(
props: ParentProps<{
sessionID: string
messageID: string
animate?: boolean
showReasoningSummaries?: boolean
shellToolDefaultOpen?: boolean
editToolDefaultOpen?: boolean
@@ -158,7 +159,11 @@ export function SessionTurn(
const i18n = useI18n()
const fileComponent = useFileComponent()
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
@@ -186,8 +191,42 @@ export function SessionTurn(
return msg
})
const active = createMemo(() => props.active ?? false)
const queued = createMemo(() => props.queued ?? false)
const pending = createMemo(() => {
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
const messages = allMessages() ?? emptyMessages
return messages.findLast(
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
)
})
const pendingUser = createMemo(() => {
const item = pending()
if (!item?.parentID) return
const messages = allMessages() ?? emptyMessages
const result = Binary.search(messages, item.parentID, (m) => m.id)
const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
if (!msg || msg.role !== "user") return
return msg
})
const active = createMemo(() => {
if (typeof props.active === "boolean") return props.active
const msg = message()
const parent = pendingUser()
if (!msg || !parent) return false
return parent.id === msg.id
})
const queued = createMemo(() => {
if (typeof props.queued === "boolean") return props.queued
const id = message()?.id
if (!id) return false
if (!pendingUser()) return false
const item = pending()
if (!item) return false
return id > item.id
})
const parts = createMemo(() => {
const msg = message()
if (!msg) return emptyParts
@@ -250,7 +289,7 @@ export function SessionTurn(
const error = createMemo(
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
)
const assistantCopyPart = createMemo(() => {
const showAssistantCopyPartID = createMemo(() => {
const messages = assistantMessages()
for (let i = messages.length - 1; i >= 0; i--) {
@@ -260,18 +299,13 @@ export function SessionTurn(
const parts = list(data.store.part?.[message.id], emptyParts)
for (let j = parts.length - 1; j >= 0; j--) {
const part = parts[j]
if (!part || part.type !== "text") continue
const text = part.text?.trim()
if (!text) continue
return {
id: part.id,
text,
message,
}
if (!part || part.type !== "text" || !part.text?.trim()) continue
return part.id
}
}
return undefined
})
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
const errorText = createMemo(() => {
const msg = error()?.data?.message
if (typeof msg === "string") return unwrap(msg)
@@ -279,14 +313,18 @@ export function SessionTurn(
return unwrap(String(msg))
})
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => {
if (status().type === "idle") return false
if (!message()) return false
return active()
const status = createMemo(() => {
if (props.status !== undefined) return props.status
if (typeof props.active === "boolean" && !props.active) return idle
return data.store.session_status[props.sessionID] ?? idle
})
const working = createMemo(() => status().type !== "idle" && active())
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
const showDiffSummary = createMemo(() => edited() > 0 && !working())
const assistantCopyPartID = createMemo(() => {
if (working()) return null
return showAssistantCopyPartID() ?? null
})
const turnDurationMs = createMemo(() => {
const start = message()?.time.created
if (typeof start !== "number") return undefined
@@ -326,109 +364,13 @@ export function SessionTurn(
.filter((text): text is string => !!text)
.at(-1),
)
const thinking = createMemo(() => {
const showThinking = createMemo(() => {
if (!working() || !!error()) return false
if (queued()) return false
if (status().type === "retry") return false
if (showReasoningSummaries()) return assistantVisible() === 0
return true
})
const hasAssistant = createMemo(() => assistantMessages().length > 0)
const animateEnabled = createMemo(() => props.animate !== false)
const [live, setLive] = createSignal(false)
const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
const duration = createMemo(() => {
const ms = turnDurationMs()
if (typeof ms !== "number" || ms < 0) return ""
const total = Math.round(ms / 1000)
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}m ${seconds}s`
})
const meta = createMemo(() => {
const item = assistantCopyPart()
if (!item) return ""
const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
const model = item.message.modelID
? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
item.message.modelID
]?.name ?? item.message.modelID)
: ""
return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
})
const [copied, setCopied] = createSignal(false)
const [handoffHold, setHandoffHold] = createSignal(false)
const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
const lane = createMemo(() => hasAssistant() || handoffOpen())
let liveFrame: number | undefined
let copiedTimer: ReturnType<typeof setTimeout> | undefined
let handoffTimer: ReturnType<typeof setTimeout> | undefined
const copyAssistant = async () => {
const text = assistantCopyPart()?.text
if (!text) return
await navigator.clipboard.writeText(text)
setCopied(true)
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
copiedTimer = setTimeout(() => {
copiedTimer = undefined
setCopied(false)
}, 2000)
}
createEffect(
on(
() => [animateEnabled(), working()] as const,
([enabled, isWorking]) => {
if (liveFrame !== undefined) {
cancelAnimationFrame(liveFrame)
liveFrame = undefined
}
if (!enabled || !isWorking || live()) return
liveFrame = requestAnimationFrame(() => {
liveFrame = undefined
setLive(true)
})
},
),
)
createEffect(
on(
() => [thinkingOpen(), metaOpen()] as const,
([thinkingNow, metaNow]) => {
if (handoffTimer !== undefined) {
clearTimeout(handoffTimer)
handoffTimer = undefined
}
if (thinkingNow) {
setHandoffHold(true)
return
}
if (metaNow) {
setHandoffHold(false)
return
}
if (!handoffHold()) return
handoffTimer = setTimeout(() => {
handoffTimer = undefined
setHandoffHold(false)
}, handoffHoldMs)
},
{ defer: true },
),
)
const autoScroll = createAutoScroll({
working,
@@ -436,119 +378,6 @@ export function SessionTurn(
overflowAnchor: "dynamic",
})
onCleanup(() => {
if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
if (handoffTimer !== undefined) clearTimeout(handoffTimer)
})
const turnDiffSummary = () => (
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "37px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</Collapsible.Content>
</Collapsible>
</div>
)
const divider = (label: string) => (
<div data-component="compaction-part">
<div data-slot="compaction-part-divider">
<span data-slot="compaction-part-line" />
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
{label}
</span>
<span data-slot="compaction-part-line" />
</div>
</div>
)
return (
<div data-component="session-turn" class={props.classes?.root}>
<div
@@ -559,120 +388,149 @@ export function SessionTurn(
>
<div onClick={autoScroll.handleInteraction}>
<Show when={message()}>
{(msg) => (
<div
ref={autoScroll.contentRef}
data-message={msg().id}
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<UserMessageDisplay
message={msg()}
parts={parts()}
interrupted={interrupted()}
animate={props.animate}
queued={queued()}
<div
ref={autoScroll.contentRef}
data-message={message()!.id}
data-slot="session-turn-message-container"
class={props.classes?.container}
>
<div data-slot="session-turn-message-content" aria-live="off">
<Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
</div>
<Show when={compaction()}>
<div data-slot="session-turn-compaction">
<Part part={compaction()!} message={message()!} hideDetails />
</div>
</Show>
<Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<AssistantParts
messages={assistantMessages()}
showAssistantCopyPartID={assistantCopyPartID()}
turnDurationMs={turnDurationMs()}
working={working()}
showReasoningSummaries={showReasoningSummaries()}
shellToolDefaultOpen={props.shellToolDefaultOpen}
editToolDefaultOpen={props.editToolDefaultOpen}
/>
</div>
<Show when={compaction()}>
{(part) => (
<GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
<div data-slot="session-turn-compaction">
<Part part={part()} message={msg()} hideDetails />
</div>
</GrowBox>
)}
</Show>
<div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
<Show when={hasAssistant()}>
<div
data-slot="session-turn-assistant-content"
aria-hidden={working()}
style={{ contain: "layout paint" }}
>
<AssistantParts
messages={assistantMessages()}
showAssistantCopyPartID={assistantCopyPartID()}
showTurnDiffSummary={showDiffSummary()}
turnDiffSummary={turnDiffSummary}
working={working()}
animate={live()}
showReasoningSummaries={showReasoningSummaries()}
shellToolDefaultOpen={props.shellToolDefaultOpen}
editToolDefaultOpen={props.editToolDefaultOpen}
/>
</div>
</Show>
<Show when={showThinking()}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!showReasoningSummaries()}>
<TextReveal
text={reasoningHeading()}
class="session-turn-thinking-heading"
travel={25}
duration={700}
/>
</Show>
<GrowBox
animate={live()}
animateToggle={live()}
open={handoffOpen()}
fade
slot="session-turn-handoff-wrap"
>
<div data-slot="session-turn-handoff">
<div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<TextReveal
text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
class="session-turn-thinking-heading"
travel={25}
duration={900}
/>
</div>
</Show>
<SessionRetry status={status()} show={active()} />
<Show when={edited() > 0 && !working()}>
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div>
</div>
<Show when={metaOpen()}>
<div
data-slot="session-turn-meta"
data-visible={thinkingVisible() ? "false" : "true"}
data-interrupted={interrupted() ? "" : undefined}
>
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
placement="top"
gutter={4}
</Collapsible.Trigger>
<Collapsible.Content>
<Show when={open()}>
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="normal"
variant="ghost"
onMouseDown={(event) => event.preventDefault()}
onClick={() => void copyAssistant()}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
/>
</Tooltip>
<Show when={meta()}>
<span
data-slot="session-turn-meta-label"
class="text-12-regular text-text-weak cursor-default"
>
{meta()}
</span>
</Show>
<For each={diffs()}>
{(diff) => {
const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect(
on(
active,
(value) => {
if (!value) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
},
{ defer: true },
),
)
return (
<Accordion.Item value={diff.file}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</div>
</Show>
</div>
</GrowBox>
</Collapsible.Content>
</Collapsible>
</div>
<GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
{divider(i18n.t("ui.message.interrupted"))}
</GrowBox>
<SessionRetry status={status()} show={active()} />
<GrowBox
animate={props.animate !== false}
fade
gap={0}
open={showDiffSummary() && !assistantCopyPartID()}
>
{turnDiffSummary()}
</GrowBox>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
)}
</Show>
<Show when={error()}>
<Card variant="error" class="error-card">
{errorText()}
</Card>
</Show>
</div>
</Show>
{props.children}
</div>
@@ -1,291 +0,0 @@
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
import stripAnsi from "strip-ansi"
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { useI18n } from "../context/i18n"
import { RollingResults } from "./rolling-results"
import { Icon } from "./icon"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { Tooltip } from "./tooltip"
import { GROW_SPRING } from "./motion"
import { useSpring } from "./motion-spring"
import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils"
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
<span data-slot="shell-rolling-subtitle">
<span ref={ref}>{props.text}</span>
</span>
)
}
function firstLine(text: string) {
return text
.split(/\r\n|\n|\r/g)
.map((item) => item.trim())
.find((item) => item.length > 0)
}
function shellRows(output: string) {
const rows: { id: string; text: string }[] = []
const lines = output
.split(/\r\n|\n|\r/g)
.map((item) => item.trimEnd())
.filter((item) => item.length > 0)
const start = Math.max(0, lines.length - 80)
for (let i = start; i < lines.length; i++) {
rows.push({ id: `line:${i}`, text: lines[i]! })
}
return rows
}
function ShellRollingCommand(props: { text: string; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
useToolFade(() => ref, { wipe: true, animate: props.animate })
return (
<div data-component="shell-rolling-command">
<span ref={ref} data-slot="shell-rolling-text">
<span data-slot="shell-rolling-prompt">$</span> {props.text}
</span>
</div>
)
}
function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
const i18n = useI18n()
const rows = 10
const rowHeight = 22
const max = rows * rowHeight
let contentRef: HTMLDivElement | undefined
let bodyRef: HTMLDivElement | undefined
let scrollRef: HTMLDivElement | undefined
let topRef: HTMLDivElement | undefined
const [copied, setCopied] = createSignal(false)
const [cap, setCap] = createSignal(max)
const updateMask = () => {
if (scrollRef) updateScrollMask(scrollRef)
}
const resize = () => {
const top = Math.ceil(topRef?.getBoundingClientRect().height ?? 0)
setCap(Math.max(rowHeight * 2, max - top - (props.out ? 1 : 0)))
}
const measure = () => {
resize()
return Math.ceil(bodyRef?.getBoundingClientRect().height ?? 0)
}
onMount(() => {
resize()
if (!topRef) return
const obs = new ResizeObserver(resize)
obs.observe(topRef)
onCleanup(() => obs.disconnect())
})
createEffect(() => {
props.cmd
props.out
queueMicrotask(() => {
resize()
updateMask()
})
})
useCollapsible({
content: () => contentRef,
body: () => bodyRef,
open: () => props.open,
measure,
onOpen: updateMask,
})
const handleCopy = async (e: MouseEvent) => {
e.stopPropagation()
const cmd = props.cmd ? `$ ${props.cmd}` : ""
const text = `${cmd}${props.out ? `${cmd ? "\n\n" : ""}${props.out}` : ""}`
if (!text) return
await navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<div ref={contentRef} style={{ overflow: "clip", height: "0px", display: "none" }}>
<div ref={bodyRef} data-component="shell-expanded-shell">
<div data-slot="shell-expanded-body">
<div ref={topRef} data-slot="shell-expanded-top">
<div data-slot="shell-expanded-command">
<span data-slot="shell-expanded-prompt">$</span>
<span data-slot="shell-expanded-input">{props.cmd}</span>
</div>
<div data-slot="shell-expanded-actions">
<Tooltip
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
placement="top"
gutter={4}
>
<IconButton
icon={copied() ? "check" : "copy"}
size="small"
variant="ghost"
class="shell-expanded-copy"
onMouseDown={(e: MouseEvent) => e.preventDefault()}
onClick={handleCopy}
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
/>
</Tooltip>
</div>
</div>
<Show when={props.out}>
<>
<div data-slot="shell-expanded-divider" />
<div
ref={scrollRef}
data-component="shell-expanded-output"
data-scrollable
onScroll={updateMask}
style={{ "max-height": `${cap()}px` }}
>
<pre data-slot="shell-expanded-pre">
<code>{props.out}</code>
</pre>
</div>
</>
</Show>
</div>
</div>
</div>
)
}
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; defaultOpen?: boolean }) {
const i18n = useI18n()
const reduce = useReducedMotion()
const wiped = new Set<string>()
const [mounted, setMounted] = createSignal(false)
const [open, setOpen] = createSignal(props.defaultOpen ?? true)
onMount(() => setMounted(true))
const state = createMemo(() => props.part.state as Record<string, any>)
const pending = createMemo(() => busy(props.part.state.status))
const expanded = createMemo(() => open() && !pending())
const previewOpen = createMemo(() => open() && pending())
const command = createMemo(() => {
const value = state().input?.command ?? state().metadata?.command
if (typeof value === "string") return value
return ""
})
const subtitle = createMemo(() => {
const value = state().input?.description ?? state().metadata?.description
if (typeof value === "string" && value.trim().length > 0) return value
return firstLine(command()) ?? ""
})
const output = createMemo(() => {
const value = state().output ?? state().metadata?.output
if (typeof value === "string") return value
return ""
})
const skip = () => reduce() || props.animate === false
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
const previewOpacity = useSpring(() => (previewOpen() ? 1 : 0), GROW_SPRING)
const previewBlur = useSpring(() => (previewOpen() ? 0 : 2), GROW_SPRING)
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
let headerClipRef: HTMLDivElement | undefined
const handleHeaderClick = () => {
const el = headerClipRef
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
const beforeY = el?.getBoundingClientRect().top ?? 0
setOpen((prev) => !prev)
if (viewport && el) {
requestAnimationFrame(() => {
const afterY = el.getBoundingClientRect().top
const delta = afterY - beforeY
if (delta !== 0) viewport.scrollTop += delta
})
}
}
const line = createMemo(() => firstLine(command()))
const fixed = createMemo(() => {
const value = line()
if (!value) return
return <ShellRollingCommand text={value} animate={props.animate} />
})
const text = createThrottledValue(() => stripAnsi(output()))
const rows = createMemo(() => shellRows(text()))
return (
<div
data-component="shell-rolling-results"
style={{ opacity: skip() ? (mounted() ? 1 : 0) : opacity(), filter: `blur(${skip() ? 0 : blur()}px)` }}
>
<div
ref={headerClipRef}
data-slot="shell-rolling-header-clip"
data-scroll-preserve
data-clickable="true"
onClick={handleHeaderClick}
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
>
<div data-slot="shell-rolling-header">
<span data-slot="shell-rolling-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
<span data-slot="shell-rolling-actions">
<span data-slot="shell-rolling-arrow" data-open={open() ? "true" : "false"}>
<Icon name="chevron-down" size="small" />
</span>
</span>
</div>
</div>
<div
data-slot="shell-rolling-preview"
style={{
opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
filter: `blur(${skip() ? 0 : previewBlur()}px)`,
}}
>
<RollingResults
class="shell-rolling-output"
noFadeOnCollapse
items={rows()}
fixed={fixed()}
fixedHeight={22}
rows={5}
rowHeight={22}
rowGap={0}
open={previewOpen()}
animate={props.animate !== false}
getKey={(row) => row.id}
render={(row) => {
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
useRowWipe({
id: () => row.id,
text: () => row.text,
ref: textRef,
seen: wiped,
})
return (
<div data-component="shell-rolling-row">
<span ref={setTextRef} data-slot="shell-rolling-text">
{row.text}
</span>
</div>
)
}}
/>
</div>
<ShellExpanded cmd={command()} out={text()} open={expanded()} />
</div>
)
}
@@ -1,13 +1,23 @@
[data-component="shell-submessage"] {
min-width: 0;
max-width: 100%;
display: inline-block;
display: inline-flex;
align-items: baseline;
vertical-align: baseline;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
min-width: 0;
max-width: 100%;
display: inline-flex;
align-items: baseline;
overflow: hidden;
}
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
display: inline-block;
vertical-align: baseline;
min-width: 0;
line-height: inherit;
white-space: nowrap;
}
+31 -30
View File
@@ -4,14 +4,14 @@
* Instead of sliding text through a fixed mask (odometer style),
* the mask itself sweeps across each span to reveal/hide text.
*
* Direction: bottom-to-top. New text rises in from below, old text exits upward.
* Direction: top-to-bottom. New text drops in from above, old text exits downward.
*
* Entering: gradient reveals bottom-to-top (bottom of text appears first).
* Entering: gradient reveals top-to-bottom (top of text appears first).
* gradient(to bottom, white 33%, transparent 33%+edge)
* pos 0 100% = transparent covers element = hidden
* pos 0 0% = white covers element = visible
*
* Leaving: gradient hides bottom-to-top (bottom of text disappears first).
* Leaving: gradient hides top-to-bottom (top of text disappears first).
* gradient(to top, white 33%, transparent 33%+edge)
* pos 0 100% = white covers element = visible
* pos 0 0% = transparent covers element = hidden
@@ -56,17 +56,17 @@
transition-timing-function: var(--_spring);
}
/* entering: reveal bottom-to-top
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Settled pos 0 0% = white covers element = visible
* Swap pos 0 100% = transparent covers = hidden
* Rises from below: translateY(travel) translateY(0)
/* entering: reveal top-to-bottom
* Gradient(to top): white at bottom, transparent at top of mask.
* Settled pos 0 100% = white covers element = visible
* Swap pos 0 0% = transparent covers = hidden
* Slides from above: translateY(-travel) translateY(0)
*/
[data-slot="text-reveal-entering"] {
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transition-property:
mask-position,
-webkit-mask-position,
@@ -74,37 +74,37 @@
transform: translateY(0);
}
/* leaving: hide bottom-to-top + slide upward
* Gradient(to top): white at bottom, transparent at top of mask.
* Swap pos 0 100% = white covers element = visible
* Settled pos 0 0% = transparent covers = hidden
* Slides up: translateY(0) translateY(-travel)
/* leaving: hide top-to-bottom + slide downward
* Gradient(to bottom): white at top, transparent at bottom of mask.
* Swap pos 0 0% = white covers element = visible
* Settled pos 0 100% = transparent covers = hidden
* Slides down: translateY(0) translateY(travel)
*/
[data-slot="text-reveal-leaving"] {
mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to top, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
-webkit-mask-image: linear-gradient(to bottom, white 33%, transparent calc(33% + var(--_edge)));
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transition-property:
mask-position,
-webkit-mask-position,
transform;
transform: translateY(calc(var(--_travel) * -1));
transform: translateY(var(--_travel));
}
/* swapping: instant reset
* Snap entering to hidden (below), leaving to visible (center).
* Snap entering to hidden (above), leaving to visible (center).
*/
&[data-swapping="true"] [data-slot="text-reveal-entering"] {
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
transform: translateY(var(--_travel));
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transform: translateY(calc(var(--_travel) * -1));
transition-duration: 0ms !important;
}
&[data-swapping="true"] [data-slot="text-reveal-leaving"] {
mask-position: 0 100%;
-webkit-mask-position: 0 100%;
mask-position: 0 0%;
-webkit-mask-position: 0 0%;
transform: translateY(0);
transition-duration: 0ms !important;
}
@@ -126,14 +126,15 @@
&[data-truncate="true"] [data-slot="text-reveal-track"] {
width: 100%;
min-width: 0;
overflow: clip;
overflow: hidden;
}
&[data-truncate="true"] [data-slot="text-reveal-entering"],
&[data-truncate="true"] [data-slot="text-reveal-leaving"] {
min-width: 0;
width: 100%;
overflow: clip;
overflow: hidden;
text-overflow: ellipsis;
}
}
+9 -106
View File
@@ -1,13 +1,4 @@
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
const px = (value: number | string | undefined, fallback: number) => {
if (typeof value === "number") return `${value}px`
@@ -26,11 +17,6 @@ const pct = (value: number | undefined, fallback: number) => {
return `${v}%`
}
const clearWipe = (el: HTMLElement) => {
clearFadeStyles(el)
clearMaskStyles(el)
}
export function TextReveal(props: {
text?: string
class?: string
@@ -53,8 +39,10 @@ export function TextReveal(props: {
let outRef: HTMLSpanElement | undefined
let rootRef: HTMLSpanElement | undefined
let frame: number | undefined
const win = () => inRef?.scrollWidth ?? 0
const wout = () => outRef?.scrollWidth ?? 0
const widen = (next: number) => {
if (next <= 0) return
if (props.growOnly ?? true) {
@@ -63,14 +51,21 @@ export function TextReveal(props: {
}
setWidth(`${next}px`)
}
createEffect(
on(
() => props.text,
(next, prev) => {
if (next === prev) return
if (typeof next === "string" && typeof prev === "string" && next.startsWith(prev)) {
setCur(next)
widen(win())
return
}
setSwapping(true)
setOld(prev)
setCur(next)
if (typeof requestAnimationFrame !== "function") {
widen(Math.max(win(), wout()))
rootRef?.offsetHeight
@@ -138,95 +133,3 @@ export function TextReveal(props: {
</span>
)
}
export function TextWipe(props: { text?: string; class?: string; delay?: number; animate?: boolean }) {
let ref: HTMLSpanElement | undefined
let frame: number | undefined
let anim: AnimationPlaybackControls | undefined
const reduce = useReducedMotion()
const run = () => {
if (props.animate === false) return
const el = ref
if (!el || !props.text || typeof window === "undefined") return
if (reduce()) return
const mask =
typeof CSS !== "undefined" &&
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
anim?.stop()
if (frame !== undefined && typeof cancelAnimationFrame === "function") {
cancelAnimationFrame(frame)
frame = undefined
}
el.style.opacity = "0"
el.style.filter = "blur(3px)"
el.style.transform = "translateX(-0.06em)"
if (mask) {
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
}
if (typeof requestAnimationFrame !== "function") {
clearWipe(el)
return
}
frame = requestAnimationFrame(() => {
frame = undefined
const node = ref
if (!node) return
anim = mask
? animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
{ ...GROW_SPRING, delay: props.delay ?? 0 },
)
: animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)" },
{ ...GROW_SPRING, delay: props.delay ?? 0 },
)
anim?.finished.then(() => {
const value = ref
if (!value) return
clearWipe(value)
})
})
}
createEffect(
on(
() => [props.text, props.animate] as const,
([text, enabled]) => {
if (!text || enabled === false) {
if (ref) clearWipe(ref)
return
}
run()
},
),
)
onCleanup(() => {
if (frame !== undefined && typeof cancelAnimationFrame === "function") cancelAnimationFrame(frame)
anim?.stop()
})
return (
<span ref={ref} class={props.class} aria-label={props.text ?? ""}>
{props.text ?? "\u00A0"}
</span>
)
}
+6 -11
View File
@@ -1,11 +1,11 @@
[data-component="text-shimmer"] {
--text-shimmer-step: 45ms;
--text-shimmer-duration: 2000ms;
--text-shimmer-duration: 1200ms;
--text-shimmer-swap: 220ms;
--text-shimmer-index: 0;
--text-shimmer-angle: 90deg;
--text-shimmer-spread: 5.2ch;
--text-shimmer-size: 600%;
--text-shimmer-size: 360%;
--text-shimmer-base-color: var(--text-weak);
--text-shimmer-peak-color: var(--text-strong);
--text-shimmer-sweep: linear-gradient(
@@ -16,17 +16,15 @@
);
--text-shimmer-base: linear-gradient(var(--text-shimmer-base-color), var(--text-shimmer-base-color));
display: inline-block;
vertical-align: baseline;
display: inline-flex;
align-items: baseline;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char"] {
display: inline-block;
position: relative;
vertical-align: baseline;
display: inline-grid;
white-space: pre;
font: inherit;
letter-spacing: inherit;
@@ -35,7 +33,7 @@
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"],
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
display: inline-block;
grid-area: 1 / 1;
white-space: pre;
transition: opacity var(--text-shimmer-swap) ease-out;
font: inherit;
@@ -44,14 +42,11 @@
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-base"] {
position: relative;
color: inherit;
opacity: 1;
}
[data-component="text-shimmer"] [data-slot="text-shimmer-char-shimmer"] {
position: absolute;
inset: 0;
color: var(--text-weaker);
opacity: 0;
}
@@ -37,16 +37,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
clearTimeout(timer)
})
const len = createMemo(() => Math.max(text().length, 1))
const shimmerSize = createMemo(() => Math.max(300, Math.round(200 + 1400 / len())))
// duration = len × (size - 1) / velocity → uniform perceived sweep speed
const VELOCITY = 0.01375 // ch per ms, ~10% faster than original 0.0125 baseline
const shimmerDuration = createMemo(() => {
const s = shimmerSize() / 100
return Math.max(1000, Math.min(2500, Math.round((len() * (s - 1)) / VELOCITY)))
})
return (
<Dynamic
component={props.as ?? "span"}
@@ -57,8 +47,6 @@ export const TextShimmer = <T extends ValidComponent = "span">(props: {
style={{
"--text-shimmer-swap": `${swap}ms`,
"--text-shimmer-index": `${offset()}`,
"--text-shimmer-size": `${shimmerSize()}%`,
"--text-shimmer-duration": `${shimmerDuration()}ms`,
}}
>
<span data-slot="text-shimmer-char">
-17
View File
@@ -1,17 +0,0 @@
/** Find the longest common character prefix between two strings. */
export function commonPrefix(a: string, b: string) {
const ac = Array.from(a)
const bc = Array.from(b)
let i = 0
while (i < ac.length && i < bc.length && ac[i] === bc[i]) i++
return {
prefix: ac.slice(0, i).join(""),
aSuffix: ac.slice(i).join(""),
bSuffix: bc.slice(i).join(""),
}
}
export function list<T>(value: T[] | undefined | null, fallback: T[]): T[] {
if (Array.isArray(value)) return value
return fallback
}
@@ -27,10 +27,10 @@
grid-template-columns: 0fr;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.42));
overflow: clip;
overflow: hidden;
transform: translateX(-0.04em);
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration: 800ms, 400ms, 400ms, 800ms;
transition-duration: 250ms, 250ms, 250ms, 250ms;
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -45,7 +45,7 @@
[data-slot="tool-count-label-suffix-inner"] {
min-width: 0;
overflow: clip;
overflow: hidden;
white-space: pre;
}
}
@@ -1,6 +1,5 @@
import { createMemo } from "solid-js"
import { AnimatedNumber } from "./animated-number"
import { commonPrefix } from "./text-utils"
function split(text: string) {
const match = /{{\s*count\s*}}/.exec(text)
@@ -12,23 +11,35 @@ function split(text: string) {
}
}
function common(one: string, other: string) {
const a = Array.from(one)
const b = Array.from(other)
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i++
return {
stem: a.slice(0, i).join(""),
one: a.slice(i).join(""),
other: b.slice(i).join(""),
}
}
export function AnimatedCountLabel(props: { count: number; one: string; other: string; class?: string }) {
const one = createMemo(() => split(props.one))
const other = createMemo(() => split(props.other))
const singular = createMemo(() => Math.round(props.count) === 1)
const active = createMemo(() => (singular() ? one() : other()))
const suffix = createMemo(() => commonPrefix(one().after, other().after))
const suffix = createMemo(() => common(one().after, other().after))
const splitSuffix = createMemo(
() =>
one().before === other().before &&
(one().after.startsWith(other().after) || other().after.startsWith(one().after)),
)
const before = createMemo(() => (splitSuffix() ? one().before : active().before))
const stem = createMemo(() => (splitSuffix() ? suffix().prefix : active().after))
const stem = createMemo(() => (splitSuffix() ? suffix().stem : active().after))
const tail = createMemo(() => {
if (!splitSuffix()) return ""
if (singular()) return suffix().aSuffix
return suffix().bSuffix
if (singular()) return suffix().one
return suffix().other
})
const showTail = createMemo(() => splitSuffix() && tail().length > 0)
@@ -10,12 +10,12 @@
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
overflow: clip;
overflow: hidden;
transform-origin: left center;
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration:
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-spring-ms, 800ms);
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 240ms), var(--tool-motion-fade-ms, 280ms),
var(--tool-motion-spring-ms, 480ms);
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -35,12 +35,12 @@
opacity: 0;
filter: blur(var(--tool-motion-blur, 2px));
transform: translateY(0.06em) scale(0.985);
overflow: clip;
overflow: hidden;
transform-origin: left center;
transition-property: grid-template-columns, opacity, filter, transform;
transition-duration:
var(--tool-motion-spring-ms, 800ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms),
var(--tool-motion-spring-ms, 800ms);
var(--tool-motion-spring-ms, 480ms), var(--tool-motion-fade-ms, 280ms), var(--tool-motion-fade-ms, 320ms),
var(--tool-motion-spring-ms, 480ms);
transition-timing-function:
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1)), ease-out, ease-out,
var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
@@ -55,7 +55,7 @@
[data-slot="tool-count-summary-empty-inner"] {
min-width: 0;
overflow: clip;
overflow: hidden;
white-space: nowrap;
}
@@ -63,7 +63,7 @@
display: inline-flex;
align-items: baseline;
min-width: 0;
overflow: clip;
overflow: hidden;
white-space: nowrap;
}
@@ -75,11 +75,12 @@
margin-right: 0;
opacity: 0;
filter: blur(calc(var(--tool-motion-blur, 2px) * 0.55));
overflow: clip;
overflow: hidden;
transform: translateX(-0.08em);
transition-property: opacity, filter, transform;
transition-duration:
var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms), var(--tool-motion-fade-ms, 400ms);
calc(var(--tool-motion-fade-ms, 200ms) * 0.75), calc(var(--tool-motion-fade-ms, 220ms) * 0.75),
calc(var(--tool-motion-fade-ms, 220ms) * 0.6);
transition-timing-function: ease-out, ease-out, ease-out;
}
@@ -18,8 +18,9 @@
[data-slot="tool-status-swap"],
[data-slot="tool-status-tail"] {
display: inline-grid;
overflow: clip;
overflow: hidden;
justify-items: start;
transition: width var(--tool-motion-spring-ms, 480ms) var(--tool-motion-ease, cubic-bezier(0.22, 1, 0.36, 1));
}
[data-slot="tool-status-active"],
@@ -30,8 +31,8 @@
text-align: start;
transition-property: opacity, filter, transform;
transition-duration:
var(--tool-motion-fade-ms, 400ms), calc(var(--tool-motion-fade-ms, 400ms) * 0.8),
calc(var(--tool-motion-fade-ms, 400ms) * 0.8);
var(--tool-motion-fade-ms, 240ms), calc(var(--tool-motion-fade-ms, 240ms) * 0.8),
calc(var(--tool-motion-fade-ms, 240ms) * 0.8);
transition-timing-function: ease-out, ease-out, ease-out;
}
@@ -1,8 +1,17 @@
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
import { TextShimmer } from "./text-shimmer"
import { commonPrefix } from "./text-utils"
function common(active: string, done: string) {
const a = Array.from(active)
const b = Array.from(done)
let i = 0
while (i < a.length && i < b.length && a[i] === b[i]) i++
return {
prefix: a.slice(0, i).join(""),
active: a.slice(i).join(""),
done: b.slice(i).join(""),
}
}
function contentWidth(el: HTMLSpanElement | undefined) {
if (!el) return 0
@@ -18,58 +27,25 @@ export function ToolStatusTitle(props: {
class?: string
split?: boolean
}) {
const reduce = useReducedMotion()
const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
const split = createMemo(() => common(props.activeText, props.doneText))
const suffix = createMemo(
() =>
(props.split ?? true) && split().prefix.length >= 2 && split().aSuffix.length > 0 && split().bSuffix.length > 0,
() => (props.split ?? true) && split().prefix.length >= 2 && split().active.length > 0 && split().done.length > 0,
)
const prefixLen = createMemo(() => Array.from(split().prefix).length)
const activeTail = createMemo(() => (suffix() ? split().aSuffix : props.activeText))
const doneTail = createMemo(() => (suffix() ? split().bSuffix : props.doneText))
const activeTail = createMemo(() => (suffix() ? split().active : props.activeText))
const doneTail = createMemo(() => (suffix() ? split().done : props.doneText))
const [width, setWidth] = createSignal("auto")
const [ready, setReady] = createSignal(false)
let activeRef: HTMLSpanElement | undefined
let doneRef: HTMLSpanElement | undefined
let swapRef: HTMLSpanElement | undefined
let tailRef: HTMLSpanElement | undefined
let frame: number | undefined
let readyFrame: number | undefined
let widthAnim: AnimationPlaybackControls | undefined
const node = () => (suffix() ? tailRef : swapRef)
const setNodeWidth = (width: string) => {
if (swapRef) swapRef.style.width = width
if (tailRef) tailRef.style.width = width
}
const measure = () => {
const target = props.active ? activeRef : doneRef
const next = contentWidth(target)
if (next <= 0) return
const ref = node()
if (!ref || !ready() || reduce()) {
widthAnim?.stop()
setNodeWidth(`${next}px`)
return
}
const prev = Math.max(0, Math.ceil(ref.getBoundingClientRect().width))
if (Math.abs(next - prev) < 1) {
ref.style.width = `${next}px`
return
}
ref.style.width = `${prev}px`
widthAnim?.stop()
widthAnim = animate(ref, { width: `${next}px` }, GROW_SPRING)
widthAnim.finished.then(() => {
const el = node()
if (!el) return
el.style.width = `${next}px`
})
const px = contentWidth(target)
if (px > 0) setWidth(`${px}px`)
}
const schedule = () => {
@@ -114,7 +90,6 @@ export function ToolStatusTitle(props: {
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
if (readyFrame !== undefined) cancelAnimationFrame(readyFrame)
widthAnim?.stop()
})
return (
@@ -129,7 +104,7 @@ export function ToolStatusTitle(props: {
<Show
when={suffix()}
fallback={
<span data-slot="tool-status-swap" ref={swapRef}>
<span data-slot="tool-status-swap" style={{ width: width() }}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={0} />
</span>
@@ -143,7 +118,7 @@ export function ToolStatusTitle(props: {
<span data-slot="tool-status-prefix">
<TextShimmer text={split().prefix} active={props.active} offset={0} />
</span>
<span data-slot="tool-status-tail" ref={tailRef}>
<span data-slot="tool-status-tail" style={{ width: width() }}>
<span data-slot="tool-status-active" ref={activeRef}>
<TextShimmer text={activeTail()} active={props.active} offset={prefixLen()} />
</span>
-336
View File
@@ -1,336 +0,0 @@
import type { ToolPart } from "@opencode-ai/sdk/v2"
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
import { useReducedMotion } from "../hooks/use-reduced-motion"
import {
animate,
type AnimationPlaybackControls,
clearFadeStyles,
clearMaskStyles,
COLLAPSIBLE_SPRING,
GROW_SPRING,
WIPE_MASK,
} from "./motion"
export const TEXT_RENDER_THROTTLE_MS = 100
export function createThrottledValue(getValue: () => string) {
const [value, setValue] = createSignal(getValue())
let timeout: ReturnType<typeof setTimeout> | undefined
let last = 0
createEffect(() => {
const next = getValue()
const now = Date.now()
const remaining = TEXT_RENDER_THROTTLE_MS - (now - last)
if (remaining <= 0) {
if (timeout) {
clearTimeout(timeout)
timeout = undefined
}
last = now
setValue(next)
return
}
if (timeout) clearTimeout(timeout)
timeout = setTimeout(() => {
last = Date.now()
setValue(next)
timeout = undefined
}, remaining)
})
onCleanup(() => {
if (timeout) clearTimeout(timeout)
})
return value
}
export function busy(status: string | undefined) {
return status === "pending" || status === "running"
}
export function hold(state: () => boolean, wait = 2000) {
const [live, setLive] = createSignal(state())
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
if (state()) {
if (timer) clearTimeout(timer)
timer = undefined
setLive(true)
return
}
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
timer = undefined
setLive(false)
}, wait)
})
onCleanup(() => {
if (timer) clearTimeout(timer)
})
return live
}
export function updateScrollMask(el: HTMLElement, fade = 12) {
const { scrollTop, scrollHeight, clientHeight } = el
const overflow = scrollHeight - clientHeight
if (overflow <= 1) {
el.style.maskImage = ""
el.style.webkitMaskImage = ""
return
}
const top = scrollTop > 1
const bottom = scrollTop < overflow - 1
const mask =
top && bottom
? `linear-gradient(to bottom, transparent 0, black ${fade}px, black calc(100% - ${fade}px), transparent 100%)`
: top
? `linear-gradient(to bottom, transparent 0, black ${fade}px)`
: bottom
? `linear-gradient(to bottom, black calc(100% - ${fade}px), transparent 100%)`
: ""
el.style.maskImage = mask
el.style.webkitMaskImage = mask
}
export function useCollapsible(options: {
content: () => HTMLElement | undefined
body: () => HTMLElement | undefined
open: () => boolean
measure?: () => number
onOpen?: () => void
}) {
const reduce = useReducedMotion()
let heightAnim: AnimationPlaybackControls | undefined
let fadeAnim: AnimationPlaybackControls | undefined
let gen = 0
createEffect(
on(options.open, (isOpen) => {
const content = options.content()
const body = options.body()
if (!content || !body) return
heightAnim?.stop()
fadeAnim?.stop()
if (reduce()) {
body.style.opacity = ""
body.style.filter = ""
if (isOpen) {
content.style.display = ""
content.style.height = "auto"
options.onOpen?.()
return
}
content.style.height = "0px"
content.style.display = "none"
return
}
const id = ++gen
if (isOpen) {
content.style.display = ""
content.style.height = "0px"
body.style.opacity = "0"
body.style.filter = "blur(2px)"
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
queueMicrotask(() => {
if (gen !== id) return
const c = options.content()
if (!c) return
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
heightAnim.finished.then(
() => {
if (gen !== id) return
c.style.height = "auto"
options.onOpen?.()
},
() => {},
)
})
return
}
const h = content.getBoundingClientRect().height
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
heightAnim.finished.then(
() => {
if (gen !== id) return
content.style.display = "none"
},
() => {},
)
}),
)
onCleanup(() => {
++gen
heightAnim?.stop()
fadeAnim?.stop()
})
}
export function useContextToolPending(parts: () => ToolPart[], working?: () => boolean) {
const anyRunning = createMemo(() => parts().some((part) => busy(part.state.status)))
const [settled, setSettled] = createSignal(false)
createEffect(() => {
if (!anyRunning() && !working?.()) setSettled(true)
})
return createMemo(() => !settled() && (!!working?.() || anyRunning()))
}
export function useRowWipe(opts: {
id: () => string
text: () => string | undefined
ref: () => HTMLElement | undefined
seen: Set<string>
}) {
const reduce = useReducedMotion()
createEffect(() => {
const id = opts.id()
const txt = opts.text()
const el = opts.ref()
if (!el) return
if (!txt) {
clearFadeStyles(el)
clearMaskStyles(el)
return
}
if (reduce() || typeof window === "undefined") {
clearFadeStyles(el)
clearMaskStyles(el)
return
}
if (opts.seen.has(id)) {
clearFadeStyles(el)
clearMaskStyles(el)
return
}
opts.seen.add(id)
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
el.style.opacity = "0"
el.style.filter = "blur(2px)"
el.style.transform = "translateX(-0.06em)"
let done = false
const clear = () => {
if (done) return
done = true
clearFadeStyles(el)
clearMaskStyles(el)
}
if (typeof requestAnimationFrame !== "function") {
clear()
return
}
let anim: AnimationPlaybackControls | undefined
let frame: number | undefined = requestAnimationFrame(() => {
frame = undefined
const node = opts.ref()
if (!node) return
anim = animate(
node,
{
opacity: [0, 1],
filter: ["blur(2px)", "blur(0px)"],
transform: ["translateX(-0.06em)", "translateX(0)"],
maskPosition: "0% 0%",
},
GROW_SPRING,
)
anim.finished.catch(() => {}).finally(clear)
})
onCleanup(() => {
if (frame !== undefined) {
cancelAnimationFrame(frame)
clear()
}
})
})
}
export function useToolFade(
ref: () => HTMLElement | undefined,
options?: { delay?: number; wipe?: boolean; animate?: boolean },
) {
let anim: AnimationPlaybackControls | undefined
let frame: number | undefined
const delay = options?.delay ?? 0
const wipe = options?.wipe ?? false
const active = options?.animate !== false
const reduce = useReducedMotion()
onMount(() => {
if (!active) return
const el = ref()
if (!el || typeof window === "undefined") return
if (reduce()) return
const mask =
wipe &&
typeof CSS !== "undefined" &&
(CSS.supports("mask-image", "linear-gradient(to right, black, transparent)") ||
CSS.supports("-webkit-mask-image", "linear-gradient(to right, black, transparent)"))
el.style.opacity = "0"
el.style.filter = wipe ? "blur(3px)" : "blur(2px)"
el.style.transform = wipe ? "translateX(-0.06em)" : "translateY(0.04em)"
if (mask) {
el.style.maskImage = WIPE_MASK
el.style.webkitMaskImage = WIPE_MASK
el.style.maskSize = "240% 100%"
el.style.webkitMaskSize = "240% 100%"
el.style.maskRepeat = "no-repeat"
el.style.webkitMaskRepeat = "no-repeat"
el.style.maskPosition = "100% 0%"
el.style.webkitMaskPosition = "100% 0%"
}
frame = requestAnimationFrame(() => {
frame = undefined
const node = ref()
if (!node) return
anim = wipe
? mask
? animate(
node,
{ opacity: 1, filter: "blur(0px)", transform: "translateX(0)", maskPosition: "0% 0%" },
{ ...GROW_SPRING, delay },
)
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateX(0)" }, { ...GROW_SPRING, delay })
: animate(node, { opacity: 1, filter: "blur(0px)", transform: "translateY(0)" }, { ...GROW_SPRING, delay })
anim?.finished.then(() => {
const value = ref()
if (!value) return
clearFadeStyles(value)
if (mask) clearMaskStyles(value)
})
})
})
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
anim?.stop()
})
}
+61 -190
View File
@@ -1,8 +1,6 @@
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { animate, type AnimationPlaybackControls } from "motion"
import { FAST_SPRING } from "../components/motion"
export interface AutoScrollOptions {
working: () => boolean
@@ -11,28 +9,13 @@ export interface AutoScrollOptions {
bottomThreshold?: number
}
const SETTLE_MS = 500
const AUTO_SCROLL_GRACE_MS = 120
const AUTO_SCROLL_EPSILON = 0.5
const MANUAL_ANCHOR_MS = 3000
const MANUAL_ANCHOR_QUIET_FRAMES = 24
export function createAutoScroll(options: AutoScrollOptions) {
let scroll: HTMLElement | undefined
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | undefined
let autoTimer: ReturnType<typeof setTimeout> | undefined
let cleanup: (() => void) | undefined
let programmaticUntil = 0
let scrollAnim: AnimationPlaybackControls | undefined
let hold:
| {
el: HTMLElement
top: number
until: number
quiet: number
frame: number | undefined
}
| undefined
let auto: { top: number; time: number } | undefined
const threshold = () => options.bottomThreshold ?? 10
@@ -44,160 +27,77 @@ export function createAutoScroll(options: AutoScrollOptions) {
const active = () => options.working() || settling
const distanceFromBottom = (el: HTMLElement) => {
// With column-reverse, scrollTop=0 is at the bottom, negative = scrolled up
return Math.abs(el.scrollTop)
return el.scrollHeight - el.clientHeight - el.scrollTop
}
const canScroll = (el: HTMLElement) => {
return el.scrollHeight - el.clientHeight > 1
}
const markProgrammatic = () => {
programmaticUntil = Date.now() + AUTO_SCROLL_GRACE_MS
}
const clearHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) cancelAnimationFrame(next.frame)
hold = undefined
}
const tickHold = () => {
const next = hold
const el = scroll
if (!next || !el) return false
if (Date.now() > next.until) {
clearHold()
return false
// Browsers can dispatch scroll events asynchronously. If new content arrives
// between us calling `scrollTo()` and the subsequent `scroll` event firing,
// the handler can see a non-zero `distanceFromBottom` and incorrectly assume
// the user scrolled.
const markAuto = (el: HTMLElement) => {
auto = {
top: Math.max(0, el.scrollHeight - el.clientHeight),
time: Date.now(),
}
if (!next.el.isConnected) {
clearHold()
if (autoTimer) clearTimeout(autoTimer)
autoTimer = setTimeout(() => {
auto = undefined
autoTimer = undefined
}, 1500)
}
const isAuto = (el: HTMLElement) => {
const a = auto
if (!a) return false
if (Date.now() - a.time > 1500) {
auto = undefined
return false
}
const current = next.el.getBoundingClientRect().top
if (!Number.isFinite(current)) {
clearHold()
return false
}
const delta = current - next.top
if (Math.abs(delta) <= AUTO_SCROLL_EPSILON) {
next.quiet += 1
if (next.quiet > MANUAL_ANCHOR_QUIET_FRAMES) {
clearHold()
return false
}
return true
}
next.quiet = 0
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
el.scrollTop += delta
markProgrammatic()
return true
return Math.abs(el.scrollTop - a.top) < 2
}
const scheduleHold = () => {
const next = hold
if (!next) return
if (next.frame !== undefined) return
next.frame = requestAnimationFrame(() => {
const value = hold
if (!value) return
value.frame = undefined
if (!tickHold()) return
scheduleHold()
})
}
const preserve = (target: HTMLElement) => {
const scrollToBottomNow = (behavior: ScrollBehavior) => {
const el = scroll
if (!el) return
if (!store.userScrolled) {
setStore("userScrolled", true)
options.onUserInteracted?.()
markAuto(el)
if (behavior === "smooth") {
el.scrollTo({ top: el.scrollHeight, behavior })
return
}
const top = target.getBoundingClientRect().top
if (!Number.isFinite(top)) return
clearHold()
hold = {
el: target,
top,
until: Date.now() + MANUAL_ANCHOR_MS,
quiet: 0,
frame: undefined,
}
scheduleHold()
// `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`.
el.scrollTop = el.scrollHeight
}
const scrollToBottom = (force: boolean) => {
if (!force && !active()) return
clearHold()
if (force && store.userScrolled) setStore("userScrolled", false)
const el = scroll
if (!el) return
if (scrollAnim) cancelSmooth()
if (!force && store.userScrolled) return
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
const distance = distanceFromBottom(el)
if (distance < 2) {
markAuto(el)
return
}
el.scrollTop = 0
markProgrammatic()
// For auto-following content we prefer immediate updates to avoid
// visible "catch up" animations while content is still settling.
scrollToBottomNow("auto")
}
const cancelSmooth = () => {
if (scrollAnim) {
scrollAnim.stop()
scrollAnim = undefined
}
}
const smoothScrollToBottom = () => {
const el = scroll
if (!el) return
cancelSmooth()
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
if (Math.abs(el.scrollTop) <= AUTO_SCROLL_EPSILON) {
markProgrammatic()
return
}
scrollAnim = animate(el.scrollTop, 0, {
...FAST_SPRING,
onUpdate: (v) => {
markProgrammatic()
el.scrollTop = v
},
onComplete: () => {
scrollAnim = undefined
markProgrammatic()
},
})
}
const stop = (input?: { hold?: boolean }) => {
if (input?.hold !== false) clearHold()
const stop = () => {
const el = scroll
if (!el) return
if (!canScroll(el)) {
@@ -206,25 +106,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
if (store.userScrolled) return
markProgrammatic()
setStore("userScrolled", true)
options.onUserInteracted?.()
}
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0) clearHold()
if (e.deltaY > 0) {
const el = scroll
if (!el) return
if (distanceFromBottom(el) >= threshold()) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (e.deltaY >= 0) return
cancelSmooth()
// If the user is scrolling within a nested scrollable region (tool output,
// code block, etc), don't treat it as leaving the "follow bottom" mode.
// Those regions opt in via `data-scrollable`.
const el = scroll
const target = e.target instanceof Element ? e.target : undefined
const nested = target?.closest("[data-scrollable]")
@@ -236,27 +126,23 @@ export function createAutoScroll(options: AutoScrollOptions) {
const el = scroll
if (!el) return
if (hold) {
if (Date.now() < programmaticUntil) return
clearHold()
}
if (!canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (distanceFromBottom(el) < threshold()) {
if (Date.now() < programmaticUntil) return
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (!store.userScrolled && Date.now() < programmaticUntil) return
// Ignore scroll events triggered by our own scrollToBottom calls.
if (!store.userScrolled && isAuto(el)) {
scrollToBottom(false)
return
}
stop({ hold: false })
stop()
}
const handleInteraction = () => {
@@ -268,11 +154,6 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
const updateOverflowAnchor = (el: HTMLElement) => {
if (hold) {
el.style.overflowAnchor = "none"
return
}
const mode = options.overflowAnchor ?? "dynamic"
if (mode === "none") {
@@ -292,17 +173,15 @@ export function createAutoScroll(options: AutoScrollOptions) {
() => store.contentRef,
() => {
const el = scroll
if (hold) {
scheduleHold()
return
}
if (el && !canScroll(el)) {
if (store.userScrolled) setStore("userScrolled", false)
markProgrammatic()
return
}
if (!active()) return
if (store.userScrolled) return
// ResizeObserver fires after layout, before paint.
// Keep the bottom locked in the same frame to avoid visible
// "jump up then catch up" artifacts while streaming content.
scrollToBottom(false)
},
)
@@ -321,11 +200,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
settling = true
settleTimer = setTimeout(() => {
settling = false
}, SETTLE_MS)
}, 300)
}),
)
createEffect(() => {
// Track `userScrolled` even before `scrollRef` is attached, so we can
// update overflow anchoring once the element exists.
store.userScrolled
const el = scroll
if (!el) return
@@ -334,8 +215,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
onCleanup(() => {
if (settleTimer) clearTimeout(settleTimer)
clearHold()
cancelSmooth()
if (autoTimer) clearTimeout(autoTimer)
if (cleanup) cleanup()
})
@@ -348,12 +228,8 @@ export function createAutoScroll(options: AutoScrollOptions) {
scroll = el
if (!el) {
clearHold()
return
}
if (!el) return
markProgrammatic()
updateOverflowAnchor(el)
el.addEventListener("wheel", handleWheel, { passive: true })
@@ -364,18 +240,13 @@ export function createAutoScroll(options: AutoScrollOptions) {
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
handleInteraction,
preserve,
pause: stop,
forceScrollToBottom: () => scrollToBottom(true),
smoothScrollToBottom,
snapToBottom: () => {
const el = scroll
if (!el) return
resume: () => {
if (store.userScrolled) setStore("userScrolled", false)
// With column-reverse, scrollTop=0 is at the bottom
el.scrollTop = 0
markProgrammatic()
scrollToBottom(true)
},
scrollToBottom: () => scrollToBottom(false),
forceScrollToBottom: () => scrollToBottom(true),
userScrolled: () => store.userScrolled,
}
}
-1
View File
@@ -1,3 +1,2 @@
export * from "./use-filtered-list"
export * from "./create-auto-scroll"
export * from "./use-reduced-motion"
@@ -1,10 +0,0 @@
import { isHydrated } from "@solid-primitives/lifecycle"
import { createMediaQuery } from "@solid-primitives/media"
import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
const query = "(prefers-reduced-motion: reduce)"
export const useReducedMotion = createHydratableSingletonRoot(() => {
const value = createMediaQuery(query)
return () => !isHydrated() || value()
})
-1
View File
@@ -40,7 +40,6 @@
@import "../components/progress-circle.css" layer(components);
@import "../components/radio-group.css" layer(components);
@import "../components/resize-handle.css" layer(components);
@import "../components/rolling-results.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/switch.css" layer(components);
+414 -406
View File
@@ -85,214 +85,218 @@
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);
--shadow-sidebar-overlay:
0 100px 80px 0 rgba(0, 0, 0, 0.29), 0 41.778px 33.422px 0 rgba(0, 0, 0, 0.21),
0 22.336px 17.869px 0 rgba(0, 0, 0, 0.17), 0 12.522px 10.017px 0 rgba(0, 0, 0, 0.14),
0 6.65px 5.32px 0 rgba(0, 0, 0, 0.12), 0 2.767px 2.214px 0 rgba(0, 0, 0, 0.08);
color-scheme: light;
--text-mix-blend-mode: multiply;
/* OC-1 fallback variables (light) */
--background-base: #f8f7f7;
--background-weak: var(--smoke-light-3);
--background-strong: var(--smoke-light-1);
/* OC-2 fallback variables (light) */
--background-base: #f8f8f8;
--background-weak: #f3f3f3;
--background-strong: #fcfcfc;
--background-stronger: #fcfcfc;
--surface-base: var(--smoke-light-alpha-2);
--base: var(--smoke-light-alpha-2);
--surface-base-hover: #0500000f;
--surface-base-active: var(--smoke-light-alpha-3);
--surface-base-interactive-active: var(--cobalt-light-alpha-3);
--base2: var(--smoke-light-alpha-2);
--base3: var(--smoke-light-alpha-2);
--surface-inset-base: var(--smoke-light-alpha-2);
--surface-inset-base-hover: var(--smoke-light-alpha-3);
--surface-inset-strong: #1f000017;
--surface-inset-strong-hover: #1f000017;
--surface-raised-base: var(--smoke-light-alpha-1);
--surface-float-base: var(--smoke-dark-1);
--surface-float-base-hover: var(--smoke-dark-2);
--surface-raised-base-hover: var(--smoke-light-alpha-2);
--surface-raised-base-active: var(--smoke-light-alpha-3);
--surface-raised-strong: var(--smoke-light-1);
--surface-raised-strong-hover: var(--white);
--surface-raised-stronger: var(--white);
--surface-raised-stronger-hover: var(--white);
--surface-weak: var(--smoke-light-alpha-3);
--surface-weaker: var(--smoke-light-alpha-4);
--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: var(--white);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);
--surface-interactive-base: var(--cobalt-light-3);
--surface-interactive-hover: #e5f0ff;
--surface-interactive-weak: var(--cobalt-light-2);
--surface-interactive-weak-hover: var(--cobalt-light-3);
--surface-success-base: var(--apple-light-3);
--surface-success-weak: var(--apple-light-2);
--surface-success-strong: var(--apple-light-9);
--surface-warning-base: var(--solaris-light-3);
--surface-warning-weak: var(--solaris-light-2);
--surface-warning-strong: var(--solaris-light-9);
--surface-critical-base: var(--ember-light-3);
--surface-critical-weak: var(--ember-light-2);
--surface-critical-strong: var(--ember-light-9);
--surface-info-base: var(--lilac-light-3);
--surface-info-weak: var(--lilac-light-2);
--surface-info-strong: var(--lilac-light-9);
--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: #feefeb;
--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: var(--smoke-light-2);
--surface-diff-hidden-base: var(--blue-light-3);
--surface-diff-hidden-weak: var(--blue-light-2);
--surface-diff-hidden-weaker: var(--blue-light-1);
--surface-diff-hidden-strong: var(--blue-light-5);
--surface-diff-hidden-stronger: var(--blue-light-9);
--surface-diff-add-base: #dafbe0;
--surface-diff-add-weak: var(--mint-light-2);
--surface-diff-add-weaker: var(--mint-light-1);
--surface-diff-add-strong: var(--mint-light-5);
--surface-diff-add-stronger: var(--mint-light-9);
--surface-diff-delete-base: var(--ember-light-3);
--surface-diff-delete-weak: var(--ember-light-2);
--surface-diff-delete-weaker: var(--ember-light-1);
--surface-diff-delete-strong: var(--ember-light-6);
--surface-diff-delete-stronger: var(--ember-light-9);
--input-base: var(--smoke-light-1);
--input-hover: var(--smoke-light-2);
--input-active: var(--cobalt-light-1);
--input-selected: var(--cobalt-light-4);
--input-focus: var(--cobalt-light-1);
--input-disabled: var(--smoke-light-4);
--text-base: var(--smoke-light-11);
--text-weak: var(--smoke-light-9);
--text-weaker: var(--smoke-light-8);
--text-strong: var(--smoke-light-12);
--text-invert-base: var(--smoke-dark-alpha-11);
--text-invert-weak: var(--smoke-dark-alpha-9);
--text-invert-weaker: var(--smoke-dark-alpha-8);
--text-invert-strong: var(--smoke-dark-alpha-12);
--text-interactive-base: var(--cobalt-light-9);
--text-on-brand-base: var(--smoke-light-alpha-11);
--text-on-interactive-base: var(--smoke-light-1);
--text-on-interactive-weak: var(--smoke-dark-alpha-11);
--text-on-success-base: var(--apple-light-10);
--text-on-critical-base: var(--ember-light-10);
--text-on-critical-weak: var(--ember-light-8);
--text-on-critical-strong: var(--ember-light-12);
--text-on-warning-base: var(--smoke-dark-alpha-11);
--text-on-info-base: var(--smoke-dark-alpha-11);
--text-diff-add-base: var(--mint-light-11);
--text-diff-delete-base: var(--ember-light-10);
--text-diff-delete-strong: var(--ember-light-12);
--text-diff-add-strong: var(--mint-light-12);
--text-on-info-weak: var(--smoke-dark-alpha-9);
--text-on-info-strong: var(--smoke-dark-alpha-12);
--text-on-warning-weak: var(--smoke-dark-alpha-9);
--text-on-warning-strong: var(--smoke-dark-alpha-12);
--text-on-success-weak: var(--apple-light-6);
--text-on-success-strong: var(--apple-light-12);
--text-on-brand-weak: var(--smoke-light-alpha-9);
--text-on-brand-weaker: var(--smoke-light-alpha-8);
--text-on-brand-strong: var(--smoke-light-alpha-12);
--button-primary-base: var(--smoke-light-12);
--button-secondary-base: #fdfcfc;
--button-secondary-hover: #faf9f9;
--border-base: var(--smoke-light-alpha-7);
--border-hover: var(--smoke-light-alpha-8);
--border-active: var(--smoke-light-alpha-9);
--border-selected: var(--cobalt-light-alpha-9);
--border-disabled: var(--smoke-light-alpha-8);
--border-focus: var(--smoke-light-alpha-9);
--border-weak-base: var(--smoke-light-alpha-5);
--border-strong-base: var(--smoke-light-alpha-7);
--border-strong-hover: var(--smoke-light-alpha-8);
--border-strong-active: var(--smoke-light-alpha-7);
--border-strong-selected: var(--cobalt-light-alpha-6);
--border-strong-disabled: var(--smoke-light-alpha-6);
--border-strong-focus: var(--smoke-light-alpha-7);
--border-weak-hover: var(--smoke-light-alpha-6);
--border-weak-active: var(--smoke-light-alpha-7);
--border-weak-selected: var(--cobalt-light-alpha-5);
--border-weak-disabled: var(--smoke-light-alpha-6);
--border-weak-focus: var(--smoke-light-alpha-7);
--border-weaker-base: var(--smoke-light-alpha-3);
--border-interactive-base: var(--cobalt-light-7);
--border-interactive-hover: var(--cobalt-light-8);
--border-interactive-active: var(--cobalt-light-9);
--border-interactive-selected: var(--cobalt-light-9);
--border-interactive-disabled: var(--smoke-light-8);
--border-interactive-focus: var(--cobalt-light-9);
--border-success-base: var(--apple-light-6);
--border-success-hover: var(--apple-light-7);
--border-success-selected: var(--apple-light-9);
--border-warning-base: var(--solaris-light-6);
--border-warning-hover: var(--solaris-light-7);
--border-warning-selected: var(--solaris-light-9);
--border-critical-base: var(--ember-light-6);
--border-critical-hover: var(--ember-light-7);
--border-critical-selected: var(--ember-light-9);
--border-info-base: var(--lilac-light-6);
--border-info-hover: var(--lilac-light-7);
--border-info-selected: var(--lilac-light-9);
--icon-base: var(--smoke-light-9);
--icon-hover: var(--smoke-light-11);
--icon-active: var(--smoke-light-12);
--icon-selected: var(--smoke-light-12);
--icon-disabled: var(--smoke-light-8);
--icon-focus: var(--smoke-light-12);
--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: var(--smoke-light-7);
--icon-weak-hover: var(--smoke-light-8);
--icon-weak-active: var(--smoke-light-9);
--icon-weak-selected: var(--smoke-light-10);
--icon-weak-disabled: var(--smoke-light-6);
--icon-weak-focus: var(--smoke-light-9);
--icon-strong-base: var(--smoke-light-12);
--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: var(--smoke-light-8);
--icon-strong-disabled: #c7c7c7;
--icon-strong-focus: #020202;
--icon-brand-base: var(--smoke-light-12);
--icon-interactive-base: var(--cobalt-light-9);
--icon-success-base: var(--apple-light-7);
--icon-success-hover: var(--apple-light-8);
--icon-success-active: var(--apple-light-11);
--icon-warning-base: var(--amber-light-9);
--icon-warning-hover: var(--amber-light-8);
--icon-warning-active: var(--amber-light-11);
--icon-critical-base: var(--ember-light-10);
--icon-critical-hover: var(--ember-light-11);
--icon-critical-active: var(--ember-light-12);
--icon-info-base: var(--lilac-light-7);
--icon-info-hover: var(--lilac-light-8);
--icon-info-active: var(--lilac-light-11);
--icon-on-brand-base: var(--smoke-light-alpha-11);
--icon-on-brand-hover: var(--smoke-light-alpha-12);
--icon-on-brand-selected: var(--smoke-light-alpha-12);
--icon-on-interactive-base: var(--smoke-light-1);
--icon-agent-plan-base: var(--purple-light-9);
--icon-agent-docs-base: var(--amber-light-9);
--icon-agent-ask-base: var(--cyan-light-9);
--icon-agent-build-base: var(--cobalt-light-9);
--icon-on-success-base: var(--apple-light-alpha-9);
--icon-on-success-hover: var(--apple-light-alpha-10);
--icon-on-success-selected: var(--apple-light-alpha-11);
--icon-on-warning-base: var(--amber-lightalpha-9);
--icon-on-warning-hover: var(--amber-lightalpha-10);
--icon-on-warning-selected: var(--amber-lightalpha-11);
--icon-on-critical-base: var(--ember-light-alpha-9);
--icon-on-critical-hover: var(--ember-light-alpha-10);
--icon-on-critical-selected: var(--ember-light-alpha-11);
--icon-on-info-base: var(--lilac-light-9);
--icon-on-info-hover: var(--lilac-light-alpha-10);
--icon-on-info-selected: var(--lilac-light-alpha-11);
--icon-diff-add-base: var(--mint-light-11);
--icon-diff-add-hover: var(--mint-light-12);
--icon-diff-add-active: var(--mint-light-12);
--icon-diff-delete-base: var(--ember-light-10);
--icon-diff-delete-hover: var(--ember-light-11);
--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);
@@ -306,12 +310,12 @@
--syntax-constant: #007b80;
--syntax-punctuation: var(--text-base);
--syntax-object: var(--text-strong);
--syntax-success: var(--apple-light-10);
--syntax-warning: var(--amber-light-10);
--syntax-critical: var(--ember-light-10);
--syntax-success: #2dba26;
--syntax-warning: #efa72e;
--syntax-critical: #ed4831;
--syntax-info: #0092a8;
--syntax-diff-add: var(--mint-light-11);
--syntax-diff-delete: var(--ember-light-11);
--syntax-diff-add: #3a8437;
--syntax-diff-delete: #ca2d17;
--syntax-diff-unknown: #ff0000;
--markdown-heading: #d68c27;
--markdown-text: #1a1a1a;
@@ -327,9 +331,6 @@
--markdown-image: #3b7dd8;
--markdown-image-text: #318795;
--markdown-code-block: #1a1a1a;
--border-color: #ffffff;
--button-ghost-hover: var(--smoke-light-alpha-2);
--button-ghost-hover2: var(--smoke-light-alpha-3);
--avatar-background-pink: #feeef8;
--avatar-background-mint: #e1fbf4;
--avatar-background-orange: #fff1e7;
@@ -342,210 +343,220 @@
--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-1 fallback variables (dark) */
--background-base: var(--smoke-dark-1);
--background-weak: #1c1717;
--background-strong: #151313;
--background-stronger: #191515;
--surface-base: var(--smoke-dark-alpha-2);
--base: var(--smoke-dark-alpha-2);
--surface-base-hover: #e0b7b716;
--surface-base-active: var(--smoke-dark-alpha-3);
--surface-base-interactive-active: var(--cobalt-dark-alpha-2);
--base2: var(--smoke-dark-alpha-2);
--base3: var(--smoke-dark-alpha-2);
--surface-inset-base: #0e0b0b7f;
--surface-inset-base-hover: #0e0b0b7f;
--surface-inset-strong: #060505cc;
--surface-inset-strong-hover: #060505cc;
--surface-raised-base: var(--smoke-dark-alpha-3);
--surface-float-base: var(--smoke-dark-1);
--surface-float-base-hover: var(--smoke-dark-2);
--surface-raised-base-hover: var(--smoke-dark-alpha-4);
--surface-raised-base-active: var(--smoke-dark-alpha-5);
--surface-raised-strong: var(--smoke-dark-alpha-4);
--surface-raised-strong-hover: var(--smoke-dark-alpha-6);
--surface-raised-stronger: var(--smoke-dark-alpha-6);
--surface-raised-stronger-hover: var(--smoke-dark-alpha-7);
--surface-weak: var(--smoke-dark-alpha-4);
--surface-weaker: var(--smoke-dark-alpha-5);
--surface-strong: var(--smoke-dark-alpha-7);
/* 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: var(--smoke-dark-3);
--surface-brand-base: var(--yuzu-light-9);
--surface-brand-hover: var(--yuzu-light-10);
--surface-interactive-base: var(--cobalt-dark-3);
--surface-interactive-hover: #0a1d4d;
--surface-interactive-weak: var(--cobalt-dark-2);
--surface-interactive-weak-hover: var(--cobalt-light-3);
--surface-success-base: var(--apple-light-3);
--surface-success-weak: var(--apple-light-2);
--surface-success-strong: var(--apple-light-9);
--surface-warning-base: var(--solaris-light-3);
--surface-warning-weak: var(--solaris-light-2);
--surface-warning-strong: var(--solaris-light-9);
--surface-critical-base: var(--ember-dark-3);
--surface-critical-weak: var(--ember-dark-2);
--surface-critical-strong: var(--ember-dark-9);
--surface-info-base: var(--lilac-light-3);
--surface-info-weak: var(--lilac-light-2);
--surface-info-strong: var(--lilac-light-9);
--surface-diff-unchanged-base: var(--smoke-dark-1);
--surface-diff-skip-base: var(--smoke-dark-alpha-1);
--surface-diff-hidden-base: var(--blue-dark-2);
--surface-diff-hidden-weak: var(--blue-dark-1);
--surface-diff-hidden-weaker: var(--blue-dark-3);
--surface-diff-hidden-strong: var(--blue-dark-5);
--surface-diff-hidden-stronger: var(--blue-dark-11);
--surface-diff-add-base: var(--mint-dark-3);
--surface-diff-add-weak: var(--mint-dark-4);
--surface-diff-add-weaker: var(--mint-dark-3);
--surface-diff-add-strong: var(--mint-dark-5);
--surface-diff-add-stronger: var(--mint-dark-11);
--surface-diff-delete-base: var(--ember-dark-3);
--surface-diff-delete-weak: var(--ember-dark-4);
--surface-diff-delete-weaker: var(--ember-dark-3);
--surface-diff-delete-strong: var(--ember-dark-5);
--surface-diff-delete-stronger: var(--ember-dark-11);
--input-base: var(--smoke-dark-2);
--input-hover: var(--smoke-dark-2);
--input-active: var(--cobalt-dark-1);
--input-selected: var(--cobalt-dark-2);
--input-focus: var(--cobalt-dark-1);
--input-disabled: var(--smoke-dark-4);
--text-base: var(--smoke-dark-alpha-11);
--text-weak: var(--smoke-dark-alpha-9);
--text-weaker: var(--smoke-dark-alpha-8);
--text-strong: var(--smoke-dark-alpha-12);
--text-invert-base: var(--smoke-dark-alpha-11);
--text-invert-weak: var(--smoke-dark-alpha-9);
--text-invert-weaker: var(--smoke-dark-alpha-8);
--text-invert-strong: var(--smoke-dark-alpha-12);
--text-interactive-base: var(--cobalt-dark-11);
--text-on-brand-base: var(--smoke-dark-alpha-11);
--text-on-interactive-base: var(--smoke-dark-12);
--text-on-interactive-weak: var(--smoke-dark-alpha-11);
--text-on-success-base: var(--apple-dark-9);
--text-on-critical-base: var(--ember-dark-9);
--text-on-critical-weak: var(--ember-dark-8);
--text-on-critical-strong: var(--ember-dark-12);
--text-on-warning-base: var(--smoke-dark-alpha-11);
--text-on-info-base: var(--smoke-dark-alpha-11);
--text-diff-add-base: var(--mint-dark-11);
--text-diff-delete-base: var(--ember-dark-9);
--text-diff-delete-strong: var(--ember-dark-12);
--text-diff-add-strong: var(--mint-dark-8);
--text-on-info-weak: var(--smoke-dark-alpha-9);
--text-on-info-strong: var(--smoke-dark-alpha-12);
--text-on-warning-weak: var(--smoke-dark-alpha-9);
--text-on-warning-strong: var(--smoke-dark-alpha-12);
--text-on-success-weak: var(--apple-dark-8);
--text-on-success-strong: var(--apple-dark-12);
--text-on-brand-weak: var(--smoke-dark-alpha-9);
--text-on-brand-weaker: var(--smoke-dark-alpha-8);
--text-on-brand-strong: var(--smoke-dark-alpha-12);
--button-primary-base: var(--smoke-dark-12);
--button-secondary-base: #231f1f;
--button-secondary-hover: #2a2727;
--border-base: var(--smoke-dark-alpha-7);
--border-hover: var(--smoke-dark-alpha-8);
--border-active: var(--smoke-dark-alpha-9);
--border-selected: var(--cobalt-dark-alpha-11);
--border-disabled: var(--smoke-dark-alpha-8);
--border-focus: var(--smoke-dark-alpha-9);
--border-weak-base: var(--smoke-dark-alpha-6);
--border-strong-base: var(--smoke-dark-alpha-8);
--border-strong-hover: var(--smoke-dark-alpha-7);
--border-strong-active: var(--smoke-dark-alpha-8);
--border-strong-selected: var(--cobalt-dark-alpha-6);
--border-strong-disabled: var(--smoke-dark-alpha-6);
--border-strong-focus: var(--smoke-dark-alpha-8);
--border-weak-hover: var(--smoke-dark-alpha-7);
--border-weak-active: var(--smoke-dark-alpha-8);
--border-weak-selected: var(--cobalt-dark-alpha-6);
--border-weak-disabled: var(--smoke-dark-alpha-6);
--border-weak-focus: var(--smoke-dark-alpha-8);
--border-interactive-base: var(--cobalt-light-7);
--border-interactive-hover: var(--cobalt-light-8);
--border-interactive-active: var(--cobalt-light-9);
--border-interactive-selected: var(--cobalt-light-9);
--border-interactive-disabled: var(--smoke-light-8);
--border-interactive-focus: var(--cobalt-light-9);
--border-success-base: var(--apple-light-6);
--border-success-hover: var(--apple-light-7);
--border-success-selected: var(--apple-light-9);
--border-warning-base: var(--solaris-light-6);
--border-warning-hover: var(--solaris-light-7);
--border-warning-selected: var(--solaris-light-9);
--border-critical-base: var(--ember-dark-5);
--border-critical-hover: var(--ember-dark-7);
--border-critical-selected: var(--ember-dark-9);
--border-info-base: var(--lilac-light-6);
--border-info-hover: var(--lilac-light-7);
--border-info-selected: var(--lilac-light-9);
--icon-base: var(--smoke-dark-9);
--icon-hover: var(--smoke-dark-10);
--icon-active: var(--smoke-dark-11);
--icon-selected: var(--smoke-dark-12);
--icon-disabled: var(--smoke-dark-7);
--icon-focus: var(--smoke-dark-12);
--icon-invert-base: var(--smoke-dark-1);
--icon-weak-base: var(--smoke-dark-6);
--icon-weak-hover: var(--smoke-light-7);
--icon-weak-active: var(--smoke-light-8);
--icon-weak-selected: var(--smoke-light-9);
--icon-weak-disabled: var(--smoke-light-4);
--icon-weak-focus: var(--smoke-light-9);
--icon-strong-base: var(--smoke-dark-12);
--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: #42120b;
--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: var(--smoke-dark-8);
--icon-strong-disabled: #3e3e3e;
--icon-strong-focus: #fdfcfc;
--icon-brand-base: var(--white);
--icon-interactive-base: var(--cobalt-dark-11);
--icon-success-base: var(--apple-dark-7);
--icon-success-hover: var(--apple-dark-8);
--icon-success-active: var(--apple-dark-11);
--icon-warning-base: var(--amber-dark-9);
--icon-warning-hover: var(--amber-dark-8);
--icon-warning-active: var(--amber-dark-11);
--icon-critical-base: var(--ember-dark-9);
--icon-critical-hover: var(--ember-dark-11);
--icon-critical-active: var(--ember-dark-12);
--icon-info-base: var(--lilac-dark-7);
--icon-info-hover: var(--lilac-dark-8);
--icon-info-active: var(--lilac-dark-11);
--icon-on-brand-base: var(--smoke-light-alpha-11);
--icon-on-brand-hover: var(--smoke-light-alpha-12);
--icon-on-brand-selected: var(--smoke-light-alpha-12);
--icon-on-interactive-base: var(--smoke-dark-12);
--icon-agent-plan-base: var(--purple-dark-9);
--icon-agent-docs-base: var(--amber-dark-9);
--icon-agent-ask-base: var(--cyan-dark-9);
--icon-agent-build-base: var(--cobalt-dark-11);
--icon-on-success-base: var(--apple-dark-alpha-9);
--icon-on-success-hover: var(--apple-dark-alpha-10);
--icon-on-success-selected: var(--apple-dark-alpha-11);
--icon-on-warning-base: var(--amber-darkalpha-9);
--icon-on-warning-hover: var(--amber-darkalpha-10);
--icon-on-warning-selected: var(--amber-darkalpha-11);
--icon-on-critical-base: var(--ember-dark-alpha-9);
--icon-on-critical-hover: var(--ember-dark-alpha-10);
--icon-on-critical-selected: var(--ember-dark-alpha-11);
--icon-on-info-base: var(--lilac-dark-9);
--icon-on-info-hover: var(--lilac-dark-alpha-10);
--icon-on-info-selected: var(--lilac-dark-alpha-11);
--icon-diff-add-base: var(--mint-dark-11);
--icon-diff-add-hover: var(--mint-dark-10);
--icon-diff-add-active: var(--mint-dark-11);
--icon-diff-delete-base: var(--ember-dark-9);
--icon-diff-delete-hover: var(--ember-dark-10);
--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);
@@ -559,12 +570,12 @@
--syntax-constant: #93e9f6;
--syntax-punctuation: var(--text-weak);
--syntax-object: var(--text-strong);
--syntax-success: var(--apple-dark-10);
--syntax-warning: var(--amber-dark-10);
--syntax-critical: var(--ember-dark-10);
--syntax-success: #35c02d;
--syntax-warning: #f5b238;
--syntax-critical: #f54f36;
--syntax-info: #93e9f6;
--syntax-diff-add: var(--mint-dark-11);
--syntax-diff-delete: var(--ember-dark-11);
--syntax-diff-add: #9bcd97;
--syntax-diff-delete: #faa494;
--syntax-diff-unknown: #ff0000;
--markdown-heading: #9d7cd8;
--markdown-text: #eeeeee;
@@ -580,10 +591,6 @@
--markdown-image: #fab283;
--markdown-image-text: #56b6c2;
--markdown-code-block: #eeeeee;
--border-color: #ffffff;
--border-weaker-base: var(--smoke-dark-alpha-2);
--button-ghost-hover: var(--smoke-dark-alpha-2);
--button-ghost-hover2: var(--smoke-dark-alpha-3);
--avatar-background-pink: #501b3f;
--avatar-background-mint: #033a34;
--avatar-background-orange: #5f2a06;
@@ -596,5 +603,6 @@
--avatar-text-purple: #9d5bd2;
--avatar-text-cyan: #369eff;
--avatar-text-lime: #c4f042;
--text-stronger: rgba(255, 255, 255, 0.936);
}
}
+67 -13
View File
@@ -1,16 +1,25 @@
import type { HexColor, OklchColor } from "./types"
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
function hue(v: number) {
return ((v % 360) + 360) % 360
}
export function hexToRgb(hex: HexColor): { r: number; g: number; b: number } {
const h = hex.replace("#", "")
const full =
h.length === 3
h.length === 3 || h.length === 4
? h
.split("")
.map((c) => c + c)
.join("")
: h
const rgb = full.length === 8 ? full.slice(0, 6) : full
const num = parseInt(full, 16)
const num = parseInt(rgb, 16)
return {
r: ((num >> 16) & 255) / 255,
g: ((num >> 8) & 255) / 255,
@@ -20,7 +29,7 @@ export function hexToRgb(hex: HexColor): { r: number; g: number; b: number } {
export function rgbToHex(r: number, g: number, b: number): HexColor {
const toHex = (v: number) => {
const clamped = Math.max(0, Math.min(1, v))
const clamped = clamp(v, 0, 1)
const int = Math.round(clamped * 255)
return int.toString(16).padStart(2, "0")
}
@@ -91,8 +100,33 @@ export function hexToOklch(hex: HexColor): OklchColor {
return rgbToOklch(r, g, b)
}
export function fitOklch(oklch: OklchColor): OklchColor {
const base = {
l: clamp(oklch.l, 0, 1),
c: Math.max(0, oklch.c),
h: hue(oklch.h),
}
const rgb = oklchToRgb(base)
if (rgb.r >= 0 && rgb.r <= 1 && rgb.g >= 0 && rgb.g <= 1 && rgb.b >= 0 && rgb.b <= 1) {
return base
}
let c = base.c
for (let i = 0; i < 24; i++) {
c *= 0.9
const next = { ...base, c }
const out = oklchToRgb(next)
if (out.r >= 0 && out.r <= 1 && out.g >= 0 && out.g <= 1 && out.b >= 0 && out.b <= 1) {
return next
}
}
return { ...base, c: 0 }
}
export function oklchToHex(oklch: OklchColor): HexColor {
const { r, g, b } = oklchToRgb(oklch)
const { r, g, b } = oklchToRgb(fitOklch(oklch))
return rgbToHex(r, g, b)
}
@@ -101,12 +135,12 @@ export function generateScale(seed: HexColor, isDark: boolean): HexColor[] {
const scale: HexColor[] = []
const lightSteps = isDark
? [0.15, 0.18, 0.22, 0.26, 0.32, 0.38, 0.46, 0.56, base.l, base.l - 0.05, 0.75, 0.93]
: [0.99, 0.97, 0.94, 0.9, 0.85, 0.79, 0.72, 0.64, base.l, base.l + 0.05, 0.45, 0.25]
? [0.182, 0.21, 0.261, 0.302, 0.341, 0.387, 0.443, 0.514, base.l, Math.max(0, base.l - 0.017), 0.8, 0.93]
: [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.548, 0.33]
const chromaMultipliers = isDark
? [0.15, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.85, 1, 1, 0.9, 0.6]
: [0.1, 0.15, 0.25, 0.35, 0.45, 0.55, 0.7, 0.85, 1, 1, 0.95, 0.85]
? [0.205, 0.275, 0.46, 0.62, 0.71, 0.79, 0.87, 0.97, 1.04, 1.03, 1, 0.58]
: [0.045, 0.128, 0.34, 0.5, 0.61, 0.69, 0.77, 0.89, 1, 1, 0.97, 0.56]
for (let i = 0; i < 12; i++) {
scale.push(
@@ -127,8 +161,8 @@ export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[
const neutralChroma = Math.min(base.c, 0.02)
const lightSteps = isDark
? [0.13, 0.16, 0.2, 0.24, 0.28, 0.33, 0.4, 0.52, 0.58, 0.66, 0.82, 0.96]
: [0.995, 0.98, 0.96, 0.94, 0.91, 0.88, 0.84, 0.78, 0.62, 0.56, 0.46, 0.2]
? [0.2, 0.226, 0.256, 0.277, 0.301, 0.325, 0.364, 0.431, base.l, 0.593, 0.706, 0.946]
: [0.991, 0.979, 0.964, 0.946, 0.931, 0.913, 0.891, 0.83, base.l, 0.617, 0.542, 0.205]
for (let i = 0; i < 12; i++) {
scale.push(
@@ -164,19 +198,39 @@ export function generateAlphaScale(scale: HexColor[], isDark: boolean): HexColor
export function mixColors(color1: HexColor, color2: HexColor, amount: number): HexColor {
const c1 = hexToOklch(color1)
const c2 = hexToOklch(color2)
const delta = ((((c2.h - c1.h) % 360) + 540) % 360) - 180
return oklchToHex({
l: c1.l + (c2.l - c1.l) * amount,
c: c1.c + (c2.c - c1.c) * amount,
h: c1.h + (c2.h - c1.h) * amount,
h: c1.h + delta * amount,
})
}
export function shift(color: HexColor, value: { l?: number; c?: number; h?: number }): HexColor {
const base = hexToOklch(color)
return oklchToHex({
l: base.l + (value.l ?? 0),
c: base.c * (value.c ?? 1),
h: base.h + (value.h ?? 0),
})
}
export function blend(color: HexColor, background: HexColor, alpha: number): HexColor {
const fg = hexToRgb(color)
const bg = hexToRgb(background)
return rgbToHex(
fg.r * alpha + bg.r * (1 - alpha),
fg.g * alpha + bg.g * (1 - alpha),
fg.b * alpha + bg.b * (1 - alpha),
)
}
export function lighten(color: HexColor, amount: number): HexColor {
const oklch = hexToOklch(color)
return oklchToHex({
...oklch,
l: Math.min(1, oklch.l + amount),
l: clamp(oklch.l + amount, 0, 1),
})
}
@@ -184,7 +238,7 @@ export function darken(color: HexColor, amount: number): HexColor {
const oklch = hexToOklch(color)
return oklchToHex({
...oklch,
l: Math.max(0, oklch.l - amount),
l: clamp(oklch.l - amount, 0, 1),
})
}
+37 -12
View File
@@ -16,6 +16,15 @@ const STORAGE_KEYS = {
const THEME_STYLE_ID = "oc-theme"
function normalize(id: string | null | undefined) {
return id === "oc-1" ? "oc-2" : id
}
function clear() {
localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT)
localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK)
}
function ensureThemeStyleElement(): HTMLStyleElement {
const existing = document.getElementById(THEME_STYLE_ID) as HTMLStyleElement | null
if (existing) return existing
@@ -35,7 +44,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da
const tokens = resolveThemeVariant(variant, isDark)
const css = themeToCss(tokens)
if (themeId !== "oc-1") {
if (themeId !== "oc-2") {
try {
localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css)
} catch {}
@@ -54,7 +63,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da
}
function cacheThemeVariants(theme: DesktopTheme, themeId: string) {
if (themeId === "oc-1") return
if (themeId === "oc-2") return
for (const mode of ["light", "dark"] as const) {
const isDark = mode === "dark"
const variant = isDark ? theme.dark : theme.light
@@ -71,7 +80,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
init: (props: { defaultTheme?: string }) => {
const [store, setStore] = createStore({
themes: DEFAULT_THEMES as Record<string, DesktopTheme>,
themeId: props.defaultTheme ?? "oc-1",
themeId: normalize(props.defaultTheme) ?? "oc-2",
colorScheme: "system" as ColorScheme,
mode: getSystemMode(),
previewThemeId: null as string | null,
@@ -89,9 +98,14 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
onCleanup(() => mediaQuery.removeEventListener("change", handler))
const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID)
const themeId = normalize(savedTheme)
const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null
if (savedTheme && store.themes[savedTheme]) {
setStore("themeId", savedTheme)
if (themeId && store.themes[themeId]) {
setStore("themeId", themeId)
}
if (savedTheme && themeId && savedTheme !== themeId) {
localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId)
clear()
}
if (savedScheme) {
setStore("colorScheme", savedScheme)
@@ -113,14 +127,23 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
const setTheme = (id: string) => {
const theme = store.themes[id]
const next = normalize(id)
if (!next) {
console.warn(`Theme "${id}" not found`)
return
}
const theme = store.themes[next]
if (!theme) {
console.warn(`Theme "${id}" not found`)
return
}
setStore("themeId", id)
localStorage.setItem(STORAGE_KEYS.THEME_ID, id)
cacheThemeVariants(theme, id)
setStore("themeId", next)
localStorage.setItem(STORAGE_KEYS.THEME_ID, next)
if (next === "oc-2") {
clear()
return
}
cacheThemeVariants(theme, next)
}
const setColorScheme = (scheme: ColorScheme) => {
@@ -138,15 +161,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
setColorScheme,
registerTheme: (theme: DesktopTheme) => setStore("themes", theme.id, theme),
previewTheme: (id: string) => {
const theme = store.themes[id]
const next = normalize(id)
if (!next) return
const theme = store.themes[next]
if (!theme) return
setStore("previewThemeId", id)
setStore("previewThemeId", next)
const previewMode = store.previewScheme
? store.previewScheme === "system"
? getSystemMode()
: store.previewScheme
: store.mode
applyThemeCss(theme, id, previewMode)
applyThemeCss(theme, next, previewMode)
},
previewColorScheme: (scheme: ColorScheme) => {
setStore("previewScheme", scheme)
-3
View File
@@ -1,5 +1,4 @@
import type { DesktopTheme } from "./types"
import oc1ThemeJson from "./themes/oc-1.json"
import oc2ThemeJson from "./themes/oc-2.json"
import tokyoThemeJson from "./themes/tokyonight.json"
import draculaThemeJson from "./themes/dracula.json"
@@ -16,7 +15,6 @@ import carbonfoxThemeJson from "./themes/carbonfox.json"
import gruvboxThemeJson from "./themes/gruvbox.json"
import auraThemeJson from "./themes/aura.json"
export const oc1Theme = oc1ThemeJson as DesktopTheme
export const oc2Theme = oc2ThemeJson as DesktopTheme
export const tokyonightTheme = tokyoThemeJson as DesktopTheme
export const draculaTheme = draculaThemeJson as DesktopTheme
@@ -34,7 +32,6 @@ export const gruvboxTheme = gruvboxThemeJson as DesktopTheme
export const auraTheme = auraThemeJson as DesktopTheme
export const DEFAULT_THEMES: Record<string, DesktopTheme> = {
"oc-1": oc1Theme,
"oc-2": oc2Theme,
aura: auraTheme,
ayu: ayuTheme,
@@ -36,12 +36,13 @@
},
"ColorValue": {
"type": "string",
"pattern": "^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|var\(--[a-z0-9-]+\))$",
"pattern": "^(#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|var\\(--[a-z0-9-]+\\))$",
"description": "Either a hex color value (#rgb/#rgba/#rrggbb/#rrggbbaa) or a CSS variable reference"
},
"ThemeSeedColors": {
"type": "object",
"description": "The minimum set of colors needed to generate a theme",
"description": "The legacy semantic seed set used to generate a theme",
"additionalProperties": false,
"required": ["neutral", "primary", "success", "warning", "error", "info", "interactive", "diffAdd", "diffDelete"],
"properties": {
"neutral": {
@@ -82,14 +83,70 @@
}
}
},
"ThemePaletteColors": {
"type": "object",
"description": "A compact semantic palette used to derive the full theme programmatically",
"additionalProperties": false,
"required": ["neutral", "primary", "success", "warning", "error", "info"],
"properties": {
"neutral": {
"$ref": "#/definitions/HexColor",
"description": "Base neutral color for generating the gray scale"
},
"ink": {
"$ref": "#/definitions/HexColor",
"description": "Optional foreground or chrome color used to derive text and border tones"
},
"primary": {
"$ref": "#/definitions/HexColor",
"description": "Primary brand color used for brand surfaces and strong emphasis"
},
"success": {
"$ref": "#/definitions/HexColor",
"description": "Success state color"
},
"warning": {
"$ref": "#/definitions/HexColor",
"description": "Warning state color"
},
"error": {
"$ref": "#/definitions/HexColor",
"description": "Error or critical state color"
},
"info": {
"$ref": "#/definitions/HexColor",
"description": "Informational state color"
},
"accent": {
"$ref": "#/definitions/HexColor",
"description": "Optional extra expressive accent for syntax and rich content"
},
"interactive": {
"$ref": "#/definitions/HexColor",
"description": "Optional dedicated interactive color; falls back to primary"
},
"diffAdd": {
"$ref": "#/definitions/HexColor",
"description": "Optional diff-add seed; falls back to a softened success color"
},
"diffDelete": {
"$ref": "#/definitions/HexColor",
"description": "Optional diff-delete seed; falls back to error"
}
}
},
"ThemeVariant": {
"type": "object",
"description": "A theme variant (light or dark) with seed colors and optional overrides",
"required": ["seeds"],
"description": "A theme variant (light or dark) with either a compact palette or legacy seeds and optional overrides",
"oneOf": [{ "required": ["seeds"] }, { "required": ["palette"] }],
"properties": {
"seeds": {
"$ref": "#/definitions/ThemeSeedColors",
"description": "Seed colors used to generate the full palette"
"description": "Legacy seed colors used to generate the full palette"
},
"palette": {
"$ref": "#/definitions/ThemePaletteColors",
"description": "Compact palette used to derive the full token set"
},
"overrides": {
"type": "object",
+4 -1
View File
@@ -1,5 +1,6 @@
export type {
DesktopTheme,
ThemePaletteColors,
ThemeSeedColors,
ThemeVariant,
HexColor,
@@ -19,7 +20,10 @@ export {
generateScale,
generateNeutralScale,
generateAlphaScale,
fitOklch,
blend,
mixColors,
shift,
lighten,
darken,
withAlpha,
@@ -31,7 +35,6 @@ export { ThemeProvider, useTheme, type ColorScheme } from "./context"
export {
DEFAULT_THEMES,
oc1Theme,
oc2Theme,
tokyonightTheme,
draculaTheme,
+1 -1
View File
@@ -27,7 +27,7 @@ export function applyTheme(theme: DesktopTheme, themeId?: string): void {
}
function buildThemeCss(light: ResolvedTheme, dark: ResolvedTheme, themeId: string): string {
const isDefaultTheme = themeId === "oc-1"
const isDefaultTheme = themeId === "oc-2"
const lightCss = themeToCss(light)
const darkCss = themeToCss(dark)
+524 -136
View File
@@ -1,27 +1,131 @@
import type { ColorValue, DesktopTheme, HexColor, ResolvedTheme, ThemeVariant } from "./types"
import { generateNeutralScale, generateScale, hexToOklch, oklchToHex, withAlpha } from "./color"
import { blend, generateNeutralScale, generateScale, hexToOklch, oklchToHex, shift, withAlpha } from "./color"
export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): ResolvedTheme {
const { seeds, overrides = {} } = variant
const colors = getColors(variant)
const { overrides = {} } = variant
const neutral = generateNeutralScale(seeds.neutral, isDark)
const primary = generateScale(seeds.primary, isDark)
const success = generateScale(seeds.success, isDark)
const warning = generateScale(seeds.warning, isDark)
const error = generateScale(seeds.error, isDark)
const info = generateScale(seeds.info, isDark)
const interactive = generateScale(seeds.interactive, isDark)
const diffAdd = generateScale(seeds.diffAdd, isDark)
const diffDelete = generateScale(seeds.diffDelete, isDark)
const neutral = generateNeutralScale(colors.neutral, isDark)
const primary = generateScale(colors.primary, isDark)
const accent = generateScale(colors.accent, isDark)
const success = generateScale(colors.success, isDark)
const warning = generateScale(colors.warning, isDark)
const error = generateScale(colors.error, isDark)
const info = generateScale(colors.info, isDark)
const interactive = generateScale(colors.interactive, isDark)
const hasInk = colors.compact && Boolean(colors.ink)
const noInk = colors.compact && !hasInk
const shadow = noInk && !isDark ? generateNeutralScale(colors.neutral, true) : neutral
const amber = generateScale(
shift(colors.warning, isDark ? { h: -16, l: -0.058, c: 1.14 } : { h: -22, l: -0.082, c: 0.94 }),
isDark,
)
const blue = generateScale(shift(colors.interactive, { h: -12, l: 0.128, c: 1.12 }), isDark)
const brandl = noInk && isDark ? generateScale(colors.primary, false) : primary
const successl = noInk && isDark ? generateScale(colors.success, false) : success
const warningl = noInk && isDark ? generateScale(colors.warning, false) : warning
const infol = noInk && isDark ? generateScale(colors.info, false) : info
const interl = noInk && isDark ? generateScale(colors.interactive, false) : interactive
const diffAdd = generateScale(
colors.diffAdd ??
(noInk
? shift(colors.success, { c: isDark ? 0.54 : 0.6, l: isDark ? 0.22 : 0.16 })
: shift(colors.success, { c: isDark ? 0.7 : 0.55, l: isDark ? -0.18 : 0.14 })),
isDark,
)
const diffDelete = generateScale(
colors.diffDelete ??
(noInk ? colors.error : shift(colors.error, { c: isDark ? 0.82 : 0.7, l: isDark ? -0.08 : 0.08 })),
isDark,
)
const ink = colors.ink ?? colors.neutral
const backgroundOverride = overrides["background-base"]
const backgroundHex = getHex(backgroundOverride)
const overlay = noInk || (Boolean(backgroundOverride) && !backgroundHex)
const content = (seed: HexColor, scale: HexColor[]) => {
const value = isDark ? seed : hexToOklch(seed).l > 0.82 ? scale[10] : seed
return shift(value, { c: isDark ? 1.16 : 1.1 })
}
const modified = () => {
if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00"
if (!hasInk) return isDark ? "#ffba92" : "#FF8C00"
const warningHue = hexToOklch(colors.warning).h
const deleteHue = hexToOklch(colors.diffDelete ?? colors.error).h
const delta = Math.abs(((((deleteHue - warningHue) % 360) + 540) % 360) - 180)
if (delta < 48) return isDark ? "#ffba92" : "#FF8C00"
return content(colors.warning, warning)
}
const surface = (
seed: HexColor,
alpha: { base: number; weak: number; weaker: number; strong: number; stronger: number },
) => {
const base = alphaTone(seed, alpha.base)
return {
base,
weak: alphaTone(seed, alpha.weak),
weaker: alphaTone(seed, alpha.weaker),
strong: alphaTone(seed, alpha.strong),
stronger: alphaTone(seed, alpha.stronger),
}
}
const compactBackground =
colors.compact && !hasInk
? isDark
? {
base: shift(blend(colors.neutral, "#000000", 0.145), { c: 0 }),
weak: shift(blend(colors.neutral, "#000000", 0.27), { c: 0 }),
strong: shift(blend(colors.neutral, "#000000", 0.165), { c: 0 }),
stronger: shift(blend(colors.neutral, "#000000", 0.19), { c: 0 }),
}
: {
base: blend(colors.neutral, "#ffffff", 0.066),
weak: blend(colors.neutral, "#ffffff", 0.11),
strong: blend(colors.neutral, "#ffffff", 0.024),
stronger: blend(colors.neutral, "#ffffff", 0.024),
}
: undefined
const compactInkBackground =
colors.compact && hasInk && isDark
? {
base: neutral[2],
weak: neutral[3],
strong: neutral[1],
stronger: neutral[2],
}
: undefined
const neutralAlpha = generateNeutralAlphaScale(neutral, isDark)
const background = backgroundHex ?? compactInkBackground?.base ?? compactBackground?.base ?? neutral[0]
const alphaTone = (color: HexColor, alpha: number) =>
overlay ? (withAlpha(color, alpha) as ColorValue) : blend(color, background, alpha)
const borderTone = (light: number, dark: number) =>
alphaTone(
ink,
isDark ? Math.min(1, dark + 0.024 + (colors.compact && hasInk ? 0.08 : 0)) : Math.min(1, light + 0.024),
)
const diffHiddenSurface = noInk
? {
base: blue[isDark ? 1 : 2],
weak: blue[isDark ? 0 : 1],
weaker: blue[isDark ? 2 : 0],
strong: blue[4],
stronger: blue[isDark ? 10 : 8],
}
: surface(
isDark ? shift(colors.interactive, { c: 0.55, l: 0 }) : shift(colors.interactive, { c: 0.45, l: 0.08 }),
isDark
? { base: 0.14, weak: 0.08, weaker: 0.18, strong: 0.26, stronger: 0.42 }
: { base: 0.12, weak: 0.08, weaker: 0.16, strong: 0.24, stronger: 0.36 },
)
const neutralAlpha = noInk ? generateNeutralOverlayScale(neutral, isDark) : generateNeutralAlphaScale(neutral, isDark)
const tokens: ResolvedTheme = {}
tokens["background-base"] = neutral[0]
tokens["background-weak"] = neutral[2]
tokens["background-strong"] = neutral[0]
tokens["background-stronger"] = isDark ? neutral[1] : "#fcfcfc"
tokens["background-base"] = compactInkBackground?.base ?? compactBackground?.base ?? neutral[0]
tokens["background-weak"] = compactInkBackground?.weak ?? compactBackground?.weak ?? neutral[2]
tokens["background-strong"] = compactInkBackground?.strong ?? compactBackground?.strong ?? neutral[0]
tokens["background-stronger"] =
compactInkBackground?.stronger ?? compactBackground?.stronger ?? (isDark ? neutral[1] : "#fcfcfc")
tokens["surface-base"] = neutralAlpha[1]
tokens["base"] = neutralAlpha[1]
@@ -37,8 +141,8 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
: (withAlpha(neutral[3], 0.09) as ColorValue)
tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"]
tokens["surface-raised-base"] = neutralAlpha[0]
tokens["surface-float-base"] = isDark ? neutral[0] : neutral[11]
tokens["surface-float-base-hover"] = isDark ? neutral[1] : neutral[10]
tokens["surface-float-base"] = isDark ? neutral[0] : noInk ? shadow[0] : neutral[11]
tokens["surface-float-base-hover"] = isDark ? neutral[1] : noInk ? shadow[1] : neutral[10]
tokens["surface-raised-base-hover"] = neutralAlpha[1]
tokens["surface-raised-base-active"] = neutralAlpha[2]
tokens["surface-raised-strong"] = isDark ? neutralAlpha[3] : neutral[0]
@@ -50,34 +154,34 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["surface-strong"] = isDark ? neutralAlpha[6] : "#ffffff"
tokens["surface-raised-stronger-non-alpha"] = isDark ? neutral[2] : "#ffffff"
tokens["surface-brand-base"] = primary[8]
tokens["surface-brand-hover"] = primary[9]
tokens["surface-brand-base"] = brandl[8]
tokens["surface-brand-hover"] = brandl[9]
tokens["surface-interactive-base"] = interactive[2]
tokens["surface-interactive-hover"] = interactive[3]
tokens["surface-interactive-weak"] = interactive[1]
tokens["surface-interactive-weak-hover"] = interactive[2]
tokens["surface-interactive-base"] = interactive[isDark ? 4 : 3]
tokens["surface-interactive-hover"] = interactive[isDark ? 5 : 4]
tokens["surface-interactive-weak"] = interactive[isDark ? 3 : 2]
tokens["surface-interactive-weak-hover"] = noInk && isDark ? interl[4] : interactive[isDark ? 4 : 3]
tokens["surface-success-base"] = success[2]
tokens["surface-success-weak"] = success[1]
tokens["surface-success-strong"] = success[8]
tokens["surface-warning-base"] = warning[2]
tokens["surface-warning-weak"] = warning[1]
tokens["surface-warning-strong"] = warning[8]
tokens["surface-critical-base"] = error[2]
tokens["surface-critical-weak"] = error[1]
tokens["surface-critical-strong"] = error[8]
tokens["surface-info-base"] = info[2]
tokens["surface-info-weak"] = info[1]
tokens["surface-info-strong"] = info[8]
tokens["surface-success-base"] = success[isDark ? 4 : 3]
tokens["surface-success-weak"] = success[isDark ? 3 : 2]
tokens["surface-success-strong"] = success[9]
tokens["surface-warning-base"] = (noInk && isDark ? warningl : warning)[isDark ? 4 : 3]
tokens["surface-warning-weak"] = (noInk && isDark ? warningl : warning)[isDark ? 3 : 2]
tokens["surface-warning-strong"] = (noInk && isDark ? warningl : warning)[9]
tokens["surface-critical-base"] = error[isDark ? 4 : 3]
tokens["surface-critical-weak"] = error[isDark ? 3 : 2]
tokens["surface-critical-strong"] = error[9]
tokens["surface-info-base"] = (noInk && isDark ? infol : info)[isDark ? 4 : 3]
tokens["surface-info-weak"] = (noInk && isDark ? infol : info)[isDark ? 3 : 2]
tokens["surface-info-strong"] = (noInk && isDark ? infol : info)[9]
tokens["surface-diff-unchanged-base"] = isDark ? neutral[0] : "#ffffff00"
tokens["surface-diff-skip-base"] = isDark ? neutralAlpha[0] : neutral[1]
tokens["surface-diff-hidden-base"] = interactive[isDark ? 1 : 2]
tokens["surface-diff-hidden-weak"] = interactive[isDark ? 0 : 1]
tokens["surface-diff-hidden-weaker"] = interactive[isDark ? 2 : 0]
tokens["surface-diff-hidden-strong"] = interactive[4]
tokens["surface-diff-hidden-stronger"] = interactive[isDark ? 10 : 8]
tokens["surface-diff-hidden-base"] = diffHiddenSurface.base
tokens["surface-diff-hidden-weak"] = diffHiddenSurface.weak
tokens["surface-diff-hidden-weaker"] = diffHiddenSurface.weaker
tokens["surface-diff-hidden-strong"] = diffHiddenSurface.strong
tokens["surface-diff-hidden-stronger"] = diffHiddenSurface.stronger
tokens["surface-diff-add-base"] = diffAdd[2]
tokens["surface-diff-add-weak"] = diffAdd[isDark ? 3 : 1]
tokens["surface-diff-add-weaker"] = diffAdd[isDark ? 2 : 0]
@@ -96,10 +200,36 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["input-focus"] = interactive[0]
tokens["input-disabled"] = neutral[3]
tokens["text-base"] = neutral[10]
tokens["text-weak"] = neutral[8]
tokens["text-weaker"] = neutral[7]
tokens["text-strong"] = neutral[11]
tokens["text-base"] = hasInk ? ink : noInk ? (isDark ? neutralAlpha[10] : neutral[10]) : neutral[10]
tokens["text-weak"] = hasInk
? shift(ink, { l: isDark ? -0.18 : 0.16, c: 0.88 })
: noInk
? isDark
? neutralAlpha[8]
: neutral[8]
: neutral[8]
tokens["text-weaker"] = hasInk
? shift(ink, { l: isDark ? -0.3 : 0.26, c: isDark ? 0.74 : 0.68 })
: noInk
? isDark
? neutralAlpha[7]
: neutral[7]
: neutral[7]
tokens["text-strong"] = hasInk
? isDark && colors.compact
? blend("#ffffff", ink, 0.82)
: shift(ink, { l: isDark ? 0.06 : -0.09, c: 1 })
: noInk
? isDark
? neutralAlpha[11]
: neutral[11]
: neutral[11]
if (noInk && isDark) {
tokens["text-base"] = withAlpha("#ffffff", 0.618) as ColorValue
tokens["text-weak"] = withAlpha("#ffffff", 0.422) as ColorValue
tokens["text-weaker"] = withAlpha("#ffffff", 0.284) as ColorValue
tokens["text-strong"] = withAlpha("#ffffff", 0.936) as ColorValue
}
tokens["text-invert-base"] = isDark ? neutral[10] : neutral[1]
tokens["text-invert-weak"] = isDark ? neutral[8] : neutral[2]
tokens["text-invert-weaker"] = isDark ? neutral[7] : neutral[3]
@@ -128,79 +258,166 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["text-on-brand-weaker"] = neutralAlpha[7]
tokens["text-on-brand-strong"] = neutralAlpha[11]
tokens["button-secondary-base"] = isDark ? neutral[2] : neutral[0]
tokens["button-secondary-hover"] = isDark ? neutral[3] : neutral[1]
tokens["button-primary-base"] = neutral[11]
tokens["button-secondary-base"] = noInk ? (isDark ? neutral[1] : neutral[0]) : isDark ? neutral[2] : neutral[0]
tokens["button-secondary-hover"] = noInk ? (isDark ? neutral[1] : neutral[1]) : isDark ? neutral[3] : neutral[1]
tokens["button-ghost-hover"] = neutralAlpha[1]
tokens["button-ghost-hover2"] = neutralAlpha[2]
tokens["border-base"] = neutralAlpha[6]
tokens["border-hover"] = neutralAlpha[7]
tokens["border-active"] = neutralAlpha[8]
tokens["border-selected"] = withAlpha(interactive[8], isDark ? 0.9 : 0.99) as ColorValue
tokens["border-disabled"] = neutralAlpha[7]
tokens["border-focus"] = neutralAlpha[8]
tokens["border-weak-base"] = neutralAlpha[isDark ? 5 : 4]
tokens["border-strong-base"] = neutralAlpha[isDark ? 7 : 6]
tokens["border-strong-hover"] = neutralAlpha[7]
tokens["border-strong-active"] = neutralAlpha[isDark ? 7 : 6]
tokens["border-strong-selected"] = withAlpha(interactive[5], 0.6) as ColorValue
tokens["border-strong-disabled"] = neutralAlpha[5]
tokens["border-strong-focus"] = neutralAlpha[isDark ? 7 : 6]
tokens["border-weak-hover"] = neutralAlpha[isDark ? 6 : 5]
tokens["border-weak-active"] = neutralAlpha[isDark ? 7 : 6]
tokens["border-weak-selected"] = withAlpha(interactive[4], isDark ? 0.6 : 0.5) as ColorValue
tokens["border-weak-disabled"] = neutralAlpha[5]
tokens["border-weak-focus"] = neutralAlpha[isDark ? 7 : 6]
tokens["border-weaker-base"] = neutralAlpha[2]
if (noInk) {
const tone = (alpha: number) => alphaTone((isDark ? "#ffffff" : "#000000") as HexColor, alpha)
if (isDark) {
tokens["surface-base"] = tone(0.031)
tokens["surface-base-hover"] = tone(0.039)
tokens["surface-base-active"] = tone(0.059)
tokens["surface-raised-base"] = tone(0.059)
tokens["surface-raised-base-hover"] = tone(0.078)
tokens["surface-raised-base-active"] = tone(0.102)
tokens["surface-raised-strong"] = tone(0.078)
tokens["surface-raised-strong-hover"] = tone(0.129)
tokens["surface-raised-stronger"] = tone(0.129)
tokens["surface-raised-stronger-hover"] = tone(0.169)
tokens["surface-weak"] = tone(0.078)
tokens["surface-weaker"] = tone(0.102)
tokens["surface-strong"] = tone(0.169)
tokens["surface-raised-stronger-non-alpha"] = neutral[1]
tokens["surface-inset-base"] = withAlpha("#000000", 0.5) as ColorValue
tokens["surface-inset-base-hover"] = tokens["surface-inset-base"]
tokens["surface-inset-strong"] = withAlpha("#000000", 0.8) as ColorValue
tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"]
tokens["button-secondary-hover"] = tone(0.039)
tokens["button-ghost-hover"] = tone(0.031)
tokens["button-ghost-hover2"] = tone(0.059)
tokens["input-base"] = neutral[1]
tokens["input-hover"] = neutral[1]
tokens["input-selected"] = interactive[1]
tokens["surface-diff-skip-base"] = "#00000000"
}
tokens["border-interactive-base"] = interactive[6]
tokens["border-interactive-hover"] = interactive[7]
tokens["border-interactive-active"] = interactive[8]
tokens["border-interactive-selected"] = interactive[8]
if (!isDark) {
tokens["surface-base"] = tone(0.031)
tokens["surface-base-hover"] = tone(0.059)
tokens["surface-base-active"] = tone(0.051)
tokens["surface-raised-base"] = tone(0.031)
tokens["surface-raised-base-hover"] = tone(0.051)
tokens["surface-raised-base-active"] = tone(0.09)
tokens["surface-raised-strong"] = neutral[0]
tokens["surface-raised-strong-hover"] = "#ffffff"
tokens["surface-raised-stronger"] = "#ffffff"
tokens["surface-raised-stronger-hover"] = "#ffffff"
tokens["surface-weak"] = tone(0.051)
tokens["surface-weaker"] = tone(0.071)
tokens["surface-strong"] = "#ffffff"
tokens["surface-raised-stronger-non-alpha"] = "#ffffff"
tokens["surface-inset-strong"] = tone(0.09)
tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"]
tokens["button-secondary-hover"] = blend("#ffffff", background, 0.04)
tokens["button-ghost-hover"] = tone(0.031)
tokens["button-ghost-hover2"] = tone(0.051)
tokens["input-base"] = neutral[0]
tokens["input-hover"] = neutral[1]
}
tokens["surface-base-interactive-active"] = withAlpha(colors.interactive, isDark ? 0.125 : 0.09) as ColorValue
}
tokens["border-base"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[6]
tokens["border-hover"] = hasInk ? borderTone(0.28, 0.2) : neutralAlpha[7]
tokens["border-active"] = hasInk ? borderTone(0.34, 0.24) : neutralAlpha[8]
tokens["border-selected"] = noInk
? isDark
? interactive[10]
: (withAlpha(colors.interactive, 0.99) as ColorValue)
: (withAlpha(interactive[8], isDark ? 0.9 : 0.99) as ColorValue)
tokens["border-disabled"] = hasInk ? borderTone(0.18, 0.12) : neutralAlpha[7]
tokens["border-focus"] = hasInk ? borderTone(0.34, 0.24) : neutralAlpha[8]
tokens["border-weak-base"] = hasInk
? borderTone(0.1, 0.08)
: noInk
? isDark
? neutral[3]
: blend(neutral[4], neutral[5], 0.5)
: neutralAlpha[isDark ? 5 : 4]
tokens["border-strong-base"] = hasInk ? borderTone(0.34, 0.24) : neutralAlpha[isDark ? 7 : 6]
tokens["border-strong-hover"] = hasInk ? borderTone(0.4, 0.28) : neutralAlpha[7]
tokens["border-strong-active"] = hasInk ? borderTone(0.46, 0.32) : neutralAlpha[isDark ? 7 : 6]
tokens["border-strong-selected"] = noInk
? (withAlpha(colors.interactive, isDark ? 0.62 : 0.31) as ColorValue)
: (withAlpha(interactive[5], 0.6) as ColorValue)
tokens["border-strong-disabled"] = hasInk ? borderTone(0.14, 0.1) : neutralAlpha[5]
tokens["border-strong-focus"] = hasInk ? borderTone(0.46, 0.32) : neutralAlpha[isDark ? 7 : 6]
tokens["border-weak-hover"] = hasInk ? borderTone(0.16, 0.12) : neutralAlpha[isDark ? 6 : 5]
tokens["border-weak-active"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[isDark ? 7 : 6]
tokens["border-weak-selected"] = noInk
? (withAlpha(colors.interactive, isDark ? 0.62 : 0.24) as ColorValue)
: (withAlpha(interactive[4], isDark ? 0.6 : 0.5) as ColorValue)
tokens["border-weak-disabled"] = hasInk ? borderTone(0.08, 0.06) : neutralAlpha[5]
tokens["border-weak-focus"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[isDark ? 7 : 6]
tokens["border-weaker-base"] = hasInk
? borderTone(0.06, 0.04)
: noInk
? isDark
? blend(neutral[1], neutral[2], 0.5)
: blend(neutral[2], neutral[3], 0.5)
: neutralAlpha[2]
if (noInk) {
const line = (l: number, d: number) => alphaTone((isDark ? "#ffffff" : "#000000") as HexColor, isDark ? d : l)
tokens["border-base"] = line(0.162, 0.195)
tokens["border-hover"] = line(0.236, 0.284)
tokens["border-active"] = line(0.46, 0.418)
tokens["border-disabled"] = tokens["border-hover"]
tokens["border-focus"] = tokens["border-active"]
}
tokens["border-interactive-base"] = (noInk && isDark ? interl : interactive)[6]
tokens["border-interactive-hover"] = (noInk && isDark ? interl : interactive)[7]
tokens["border-interactive-active"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-interactive-selected"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-interactive-disabled"] = neutral[7]
tokens["border-interactive-focus"] = interactive[8]
tokens["border-interactive-focus"] = (noInk && isDark ? interl : interactive)[8]
tokens["border-success-base"] = success[5]
tokens["border-success-hover"] = success[6]
tokens["border-success-selected"] = success[8]
tokens["border-warning-base"] = warning[5]
tokens["border-warning-hover"] = warning[6]
tokens["border-warning-selected"] = warning[8]
tokens["border-success-base"] = (noInk && isDark ? successl : success)[5]
tokens["border-success-hover"] = (noInk && isDark ? successl : success)[6]
tokens["border-success-selected"] = (noInk && isDark ? successl : success)[8]
tokens["border-warning-base"] = (noInk && isDark ? warningl : warning)[5]
tokens["border-warning-hover"] = (noInk && isDark ? warningl : warning)[6]
tokens["border-warning-selected"] = (noInk && isDark ? warningl : warning)[8]
tokens["border-critical-base"] = error[isDark ? 4 : 5]
tokens["border-critical-hover"] = error[6]
tokens["border-critical-selected"] = error[8]
tokens["border-info-base"] = info[5]
tokens["border-info-hover"] = info[6]
tokens["border-info-selected"] = info[8]
tokens["border-info-base"] = (noInk && isDark ? infol : info)[5]
tokens["border-info-hover"] = (noInk && isDark ? infol : info)[6]
tokens["border-info-selected"] = (noInk && isDark ? infol : info)[8]
tokens["border-color"] = "#ffffff"
tokens["icon-base"] = neutral[8]
tokens["icon-hover"] = neutral[isDark ? 9 : 10]
tokens["icon-active"] = neutral[isDark ? 10 : 11]
tokens["icon-selected"] = neutral[11]
tokens["icon-base"] = hasInk && !isDark ? tokens["text-weak"] : neutral[isDark ? 9 : 8]
tokens["icon-hover"] = hasInk && !isDark ? tokens["text-base"] : neutral[10]
tokens["icon-active"] = hasInk && !isDark ? tokens["text-strong"] : neutral[11]
tokens["icon-selected"] = hasInk && !isDark ? tokens["text-strong"] : neutral[11]
tokens["icon-disabled"] = neutral[isDark ? 6 : 7]
tokens["icon-focus"] = neutral[11]
tokens["icon-focus"] = hasInk && !isDark ? tokens["text-strong"] : neutral[11]
tokens["icon-invert-base"] = isDark ? neutral[0] : "#ffffff"
tokens["icon-weak-base"] = neutral[isDark ? 5 : 6]
tokens["icon-weak-hover"] = neutral[6]
tokens["icon-weak-active"] = neutral[7]
tokens["icon-weak-selected"] = neutral[8]
tokens["icon-weak-disabled"] = neutral[isDark ? 3 : 5]
tokens["icon-weak-hover"] = noInk && isDark ? blend(neutral[11], neutral[10], 0.74) : neutral[isDark ? 11 : 7]
tokens["icon-weak-active"] = noInk && isDark ? blend(neutral[11], neutral[10], 0.52) : neutral[8]
tokens["icon-weak-selected"] = neutral[isDark ? 8 : 9]
tokens["icon-weak-disabled"] = noInk && isDark ? neutral[11] : neutral[isDark ? 3 : 5]
tokens["icon-weak-focus"] = neutral[8]
tokens["icon-strong-base"] = neutral[11]
tokens["icon-strong-hover"] = isDark ? "#f6f3f3" : "#151313"
tokens["icon-strong-active"] = isDark ? "#fcfcfc" : "#020202"
tokens["icon-strong-selected"] = isDark ? "#fdfcfc" : "#020202"
tokens["icon-strong-disabled"] = neutral[7]
tokens["icon-strong-disabled"] = noInk && isDark ? neutral[6] : neutral[7]
tokens["icon-strong-focus"] = isDark ? "#fdfcfc" : "#020202"
tokens["icon-brand-base"] = isDark ? "#ffffff" : neutral[11]
tokens["icon-interactive-base"] = interactive[8]
tokens["icon-success-base"] = success[isDark ? 6 : 6]
tokens["icon-success-hover"] = success[7]
tokens["icon-success-base"] = success[isDark ? 8 : 6]
tokens["icon-success-hover"] = success[isDark ? 9 : 7]
tokens["icon-success-active"] = success[10]
tokens["icon-warning-base"] = warning[6]
tokens["icon-warning-hover"] = warning[7]
tokens["icon-warning-active"] = warning[10]
tokens["icon-warning-base"] = amber[isDark ? 8 : 6]
tokens["icon-warning-hover"] = amber[7]
tokens["icon-warning-active"] = amber[10]
tokens["icon-critical-base"] = error[isDark ? 8 : 9]
tokens["icon-critical-hover"] = error[10]
tokens["icon-critical-active"] = error[11]
@@ -213,16 +430,16 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["icon-on-interactive-base"] = isDark ? neutral[11] : neutral[0]
tokens["icon-agent-plan-base"] = info[8]
tokens["icon-agent-docs-base"] = warning[8]
tokens["icon-agent-ask-base"] = interactive[8]
tokens["icon-agent-docs-base"] = amber[8]
tokens["icon-agent-ask-base"] = blue[8]
tokens["icon-agent-build-base"] = interactive[isDark ? 10 : 8]
tokens["icon-on-success-base"] = withAlpha(success[8], 0.9) as ColorValue
tokens["icon-on-success-hover"] = withAlpha(success[9], 0.9) as ColorValue
tokens["icon-on-success-selected"] = withAlpha(success[10], 0.9) as ColorValue
tokens["icon-on-warning-base"] = withAlpha(warning[8], 0.9) as ColorValue
tokens["icon-on-warning-hover"] = withAlpha(warning[9], 0.9) as ColorValue
tokens["icon-on-warning-selected"] = withAlpha(warning[10], 0.9) as ColorValue
tokens["icon-on-warning-base"] = withAlpha(amber[8], 0.9) as ColorValue
tokens["icon-on-warning-hover"] = withAlpha(amber[9], 0.9) as ColorValue
tokens["icon-on-warning-selected"] = withAlpha(amber[10], 0.9) as ColorValue
tokens["icon-on-critical-base"] = withAlpha(error[8], 0.9) as ColorValue
tokens["icon-on-critical-hover"] = withAlpha(error[9], 0.9) as ColorValue
tokens["icon-on-critical-selected"] = withAlpha(error[10], 0.9) as ColorValue
@@ -235,42 +452,120 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens["icon-diff-add-active"] = diffAdd[isDark ? 10 : 11]
tokens["icon-diff-delete-base"] = diffDelete[isDark ? 8 : 9]
tokens["icon-diff-delete-hover"] = diffDelete[isDark ? 9 : 10]
tokens["icon-diff-modified-base"] = isDark ? "#ffba92" : "#FF8C00"
tokens["icon-diff-modified-base"] = modified()
tokens["syntax-comment"] = "var(--text-weak)"
tokens["syntax-regexp"] = "var(--text-base)"
tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656"
tokens["syntax-keyword"] = "var(--text-weak)"
tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804"
tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-variable"] = "var(--text-strong)"
tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8"
tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600"
tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80"
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = warning[9]
tokens["syntax-critical"] = error[isDark ? 9 : 9]
tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8"
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
tokens["syntax-diff-unknown"] = "#ff0000"
if (colors.compact) {
if (!hasInk) {
tokens["syntax-comment"] = "var(--text-weak)"
tokens["syntax-regexp"] = "var(--text-base)"
tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656"
tokens["syntax-keyword"] = "var(--text-weak)"
tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804"
tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-variable"] = "var(--text-strong)"
tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8"
tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600"
tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80"
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = amber[9]
tokens["syntax-critical"] = error[9]
tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8"
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
tokens["syntax-diff-unknown"] = "#ff0000"
tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27"
tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a"
tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57"
tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f"
tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f"
tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27"
tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a"
tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a"
tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27"
tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a"
tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57"
tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f"
tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f"
tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27"
tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a"
tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a"
}
if (hasInk) {
tokens["syntax-comment"] = "var(--text-weak)"
tokens["syntax-regexp"] = "var(--text-base)"
tokens["syntax-string"] = content(colors.success, success)
tokens["syntax-keyword"] = "var(--text-weak)"
tokens["syntax-primitive"] = content(colors.accent, accent)
tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-variable"] = "var(--text-strong)"
tokens["syntax-property"] = content(colors.primary, primary)
tokens["syntax-type"] = content(colors.warning, warning)
tokens["syntax-constant"] = content(colors.info, info)
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = amber[9]
tokens["syntax-critical"] = error[9]
tokens["syntax-info"] = content(colors.info, info)
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
tokens["syntax-diff-unknown"] = "#ff0000"
tokens["markdown-heading"] = content(colors.primary, primary)
tokens["markdown-text"] = tokens["text-base"]
tokens["markdown-link"] = content(colors.interactive, interactive)
tokens["markdown-link-text"] = content(colors.info, info)
tokens["markdown-code"] = content(colors.success, success)
tokens["markdown-block-quote"] = content(colors.warning, warning)
tokens["markdown-emph"] = content(colors.warning, warning)
tokens["markdown-strong"] = content(colors.accent, accent)
tokens["markdown-horizontal-rule"] = tokens["border-base"]
tokens["markdown-list-item"] = content(colors.interactive, interactive)
tokens["markdown-list-enumeration"] = content(colors.info, info)
tokens["markdown-image"] = content(colors.interactive, interactive)
tokens["markdown-image-text"] = content(colors.info, info)
tokens["markdown-code-block"] = tokens["text-base"]
}
}
if (!colors.compact) {
tokens["syntax-comment"] = "var(--text-weak)"
tokens["syntax-regexp"] = "var(--text-base)"
tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656"
tokens["syntax-keyword"] = "var(--text-weak)"
tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804"
tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-variable"] = "var(--text-strong)"
tokens["syntax-property"] = isDark ? "#ff9ae2" : "#ed6dc8"
tokens["syntax-type"] = isDark ? "#ecf58c" : "#596600"
tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80"
tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)"
tokens["syntax-object"] = "var(--text-strong)"
tokens["syntax-success"] = success[9]
tokens["syntax-warning"] = amber[9]
tokens["syntax-critical"] = error[9]
tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8"
tokens["syntax-diff-add"] = diffAdd[10]
tokens["syntax-diff-delete"] = diffDelete[10]
tokens["syntax-diff-unknown"] = "#ff0000"
tokens["markdown-heading"] = isDark ? "#9d7cd8" : "#d68c27"
tokens["markdown-text"] = isDark ? "#eeeeee" : "#1a1a1a"
tokens["markdown-link"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-link-text"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-code"] = isDark ? "#7fd88f" : "#3d9a57"
tokens["markdown-block-quote"] = isDark ? "#e5c07b" : "#b0851f"
tokens["markdown-emph"] = isDark ? "#e5c07b" : "#b0851f"
tokens["markdown-strong"] = isDark ? "#f5a742" : "#d68c27"
tokens["markdown-horizontal-rule"] = isDark ? "#808080" : "#8a8a8a"
tokens["markdown-list-item"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-list-enumeration"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-image"] = isDark ? "#fab283" : "#3b7dd8"
tokens["markdown-image-text"] = isDark ? "#56b6c2" : "#318795"
tokens["markdown-code-block"] = isDark ? "#eeeeee" : "#1a1a1a"
}
tokens["avatar-background-pink"] = isDark ? "#501b3f" : "#feeef8"
tokens["avatar-background-mint"] = isDark ? "#033a34" : "#e1fbf4"
@@ -289,13 +584,101 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res
tokens[key] = value
}
if (hasInk && "text-weak" in overrides && !("text-weaker" in overrides)) {
const weak = tokens["text-weak"]
if (weak.startsWith("#")) {
tokens["text-weaker"] = shift(weak as HexColor, { l: isDark ? -0.12 : 0.12, c: 0.75 })
} else {
tokens["text-weaker"] = weak
}
}
if (colors.compact && hasInk) {
if (!("markdown-text" in overrides)) {
tokens["markdown-text"] = tokens["text-base"]
}
if (!("markdown-code-block" in overrides)) {
tokens["markdown-code-block"] = tokens["text-base"]
}
}
if (!("text-stronger" in overrides)) {
tokens["text-stronger"] = tokens["text-strong"]
}
return tokens
}
interface ThemeColors {
compact: boolean
neutral: HexColor
ink?: HexColor
primary: HexColor
accent: HexColor
success: HexColor
warning: HexColor
error: HexColor
info: HexColor
interactive: HexColor
diffAdd?: HexColor
diffDelete?: HexColor
}
function getColors(variant: ThemeVariant): ThemeColors {
const input = variant as { palette?: unknown; seeds?: unknown }
if (input.palette && input.seeds) {
throw new Error("Theme variant cannot define both `palette` and `seeds`")
}
if (variant.palette) {
return {
compact: true,
neutral: variant.palette.neutral,
ink: variant.palette.ink,
primary: variant.palette.primary,
accent: variant.palette.accent ?? variant.palette.info,
success: variant.palette.success,
warning: variant.palette.warning,
error: variant.palette.error,
info: variant.palette.info,
interactive: variant.palette.interactive ?? variant.palette.primary,
diffAdd: variant.palette.diffAdd,
diffDelete: variant.palette.diffDelete,
}
}
if (variant.seeds) {
return {
compact: false,
neutral: variant.seeds.neutral,
ink: undefined,
primary: variant.seeds.primary,
accent: variant.seeds.info,
success: variant.seeds.success,
warning: variant.seeds.warning,
error: variant.seeds.error,
info: variant.seeds.info,
interactive: variant.seeds.interactive,
diffAdd: variant.seeds.diffAdd,
diffDelete: variant.seeds.diffDelete,
}
}
throw new Error("Theme variant requires `palette` or `seeds`")
}
function generateNeutralOverlayScale(neutralScale: HexColor[], isDark: boolean): ColorValue[] {
const alphas = isDark
? [0, 0.034, 0.063, 0.084, 0.109, 0.138, 0.181, 0.266, 0.404, 0.468, 0.603, 0.928]
: [0.014, 0.034, 0.055, 0.075, 0.096, 0.118, 0.151, 0.232, 0.453, 0.492, 0.574, 0.915]
const color = (isDark ? "#ffffff" : "#000000") as HexColor
return alphas.map((alpha) => withAlpha(color, alpha) as ColorValue)
}
function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): HexColor[] {
const alphas = isDark
? [0.02, 0.04, 0.08, 0.12, 0.16, 0.2, 0.26, 0.36, 0.44, 0.52, 0.72, 0.94]
: [0.01, 0.03, 0.06, 0.09, 0.12, 0.15, 0.2, 0.27, 0.46, 0.61, 0.5, 0.87]
? [0.024, 0.048, 0.088, 0.128, 0.17, 0.215, 0.275, 0.38, 0.46, 0.54, 0.74, 0.95]
: [0.014, 0.034, 0.066, 0.098, 0.128, 0.158, 0.208, 0.282, 0.47, 0.625, 0.515, 0.88]
return neutralScale.map((hex, i) => {
const baseOklch = hexToOklch(hex)
@@ -307,6 +690,11 @@ function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): H
})
}
function getHex(value: ColorValue | undefined): HexColor | undefined {
if (!value?.startsWith("#")) return
return value as HexColor
}
export function resolveTheme(theme: DesktopTheme): { light: ResolvedTheme; dark: ResolvedTheme } {
return {
light: resolveThemeVariant(theme.light, false),
+8 -100
View File
@@ -3,129 +3,37 @@
"name": "Aura",
"id": "aura",
"light": {
"seeds": {
"palette": {
"neutral": "#f5f0ff",
"ink": "#2d2640",
"primary": "#a277ff",
"accent": "#d94f4f",
"success": "#40bf7a",
"warning": "#d9a24a",
"error": "#d94f4f",
"info": "#5bb8d9",
"interactive": "#a277ff",
"diffAdd": "#b3e6cc",
"diffDelete": "#f5b3b3"
},
"overrides": {
"background-base": "#f5f0ff",
"background-weak": "#efe8fc",
"background-strong": "#faf7ff",
"background-stronger": "#fdfcff",
"border-weak-base": "#e0d6f2",
"border-weak-hover": "#d5c9eb",
"border-weak-active": "#cbbee3",
"border-weak-selected": "#c0b3dc",
"border-weak-disabled": "#f9f6ff",
"border-weak-focus": "#c5b8df",
"border-base": "#b5a6d4",
"border-hover": "#aa99cc",
"border-active": "#9f8dc4",
"border-selected": "#9480bc",
"border-disabled": "#ede7f9",
"border-focus": "#a593c8",
"border-strong-base": "#8068a8",
"border-strong-hover": "#735a9c",
"border-strong-active": "#664d90",
"border-strong-selected": "#5a4184",
"border-strong-disabled": "#d4c8ed",
"border-strong-focus": "#6d5396",
"surface-diff-add-base": "#e8f5ed",
"surface-diff-delete-base": "#fae8e8",
"surface-diff-hidden-base": "#e8e4f5",
"text-base": "#2d2640",
"text-weak": "#5c5270",
"text-strong": "#15101f",
"syntax-string": "#40bf7a",
"syntax-primitive": "#d94f4f",
"syntax-property": "#a277ff",
"syntax-type": "#d9a24a",
"syntax-constant": "#5bb8d9",
"syntax-info": "#5bb8d9",
"markdown-heading": "#a277ff",
"markdown-text": "#2d2640",
"markdown-link": "#c17ac8",
"markdown-link-text": "#a277ff",
"markdown-code": "#40bf7a",
"markdown-block-quote": "#6d6d6d",
"markdown-emph": "#d9a24a",
"markdown-strong": "#a277ff",
"markdown-horizontal-rule": "#d4c8ed",
"markdown-list-item": "#a277ff",
"markdown-list-enumeration": "#a277ff",
"markdown-image": "#c17ac8",
"markdown-image-text": "#a277ff",
"markdown-code-block": "#5bb8d9"
"syntax-keyword": "#7b5ae0"
}
},
"dark": {
"seeds": {
"palette": {
"neutral": "#15141b",
"ink": "#edecee",
"primary": "#a277ff",
"accent": "#ff6767",
"success": "#61ffca",
"warning": "#ffca85",
"error": "#ff6767",
"info": "#82e2ff",
"interactive": "#a277ff",
"diffAdd": "#61ffca",
"diffDelete": "#ff6767"
},
"overrides": {
"background-base": "#15141b",
"background-weak": "#1a1921",
"background-strong": "#121118",
"background-stronger": "#0f0e14",
"border-weak-base": "#2d2b38",
"border-weak-hover": "#332f42",
"border-weak-active": "#38354c",
"border-weak-selected": "#3e3a56",
"border-weak-disabled": "#1a1921",
"border-weak-focus": "#363350",
"border-base": "#433f5a",
"border-hover": "#4a4565",
"border-active": "#514c70",
"border-selected": "#58527b",
"border-disabled": "#1f1e28",
"border-focus": "#4e496c",
"border-strong-base": "#635c8a",
"border-strong-hover": "#6d6597",
"border-strong-active": "#776fa4",
"border-strong-selected": "#8179b1",
"border-strong-disabled": "#2a283a",
"border-strong-focus": "#716a9e",
"surface-diff-add-base": "#162620",
"surface-diff-delete-base": "#26161a",
"surface-diff-hidden-base": "#1e1d2a",
"text-base": "#edecee",
"text-weak": "#6d6d6d",
"text-strong": "#ffffff",
"syntax-string": "#61ffca",
"syntax-primitive": "#ff6767",
"syntax-property": "#a277ff",
"syntax-type": "#ffca85",
"syntax-constant": "#82e2ff",
"syntax-info": "#82e2ff",
"markdown-heading": "#a277ff",
"markdown-text": "#edecee",
"markdown-link": "#f694ff",
"markdown-link-text": "#a277ff",
"markdown-code": "#61ffca",
"markdown-block-quote": "#6d6d6d",
"markdown-emph": "#ffca85",
"markdown-strong": "#a277ff",
"markdown-horizontal-rule": "#2d2b38",
"markdown-list-item": "#a277ff",
"markdown-list-enumeration": "#a277ff",
"markdown-image": "#f694ff",
"markdown-image-text": "#a277ff",
"markdown-code-block": "#edecee"
"syntax-keyword": "#a277ff"
}
}
}
+8 -102
View File
@@ -3,131 +3,37 @@
"name": "Ayu",
"id": "ayu",
"light": {
"seeds": {
"palette": {
"neutral": "#fdfaf4",
"ink": "#4f5964",
"primary": "#4aa8c8",
"accent": "#ef7d71",
"success": "#5fb978",
"warning": "#ea9f41",
"error": "#e6656a",
"info": "#2f9bce",
"interactive": "#4aa8c8",
"diffAdd": "#b1d780",
"diffDelete": "#e6656a"
},
"overrides": {
"background-base": "#fdfaf4",
"background-weak": "#fcf9f3",
"background-strong": "#fbf8f2",
"background-stronger": "#faf7f1",
"surface-raised-base-hover": "#f4f0e9",
"border-weak-base": "#e6ddcf",
"border-weak-hover": "#dcd3c5",
"border-weak-active": "#d1c9ba",
"border-weak-selected": "#c6bfaf",
"border-weak-disabled": "#f7f0e6",
"border-weak-focus": "#cbc4b6",
"border-base": "#bfb3a3",
"border-hover": "#b4a898",
"border-active": "#a99e8e",
"border-selected": "#9e9383",
"border-disabled": "#efe5d8",
"border-focus": "#b09f8f",
"border-strong-base": "#837765",
"border-strong-hover": "#7a6f5f",
"border-strong-active": "#716655",
"border-strong-selected": "#685e4e",
"border-strong-disabled": "#d8cabc",
"border-strong-focus": "#766b5c",
"surface-diff-add-base": "#eef5e4",
"surface-diff-delete-base": "#fde5e5",
"surface-diff-hidden-base": "#e3edf3",
"text-base": "#4f5964",
"text-weak": "#77818d",
"text-strong": "#1b232b",
"syntax-string": "#7fad00",
"syntax-primitive": "#ef7d71",
"syntax-property": "#4aa8c8",
"syntax-type": "#ed982e",
"syntax-constant": "#2f9bce",
"syntax-info": "#2f9bce",
"markdown-heading": "#4aa8c8",
"markdown-text": "#4f5964",
"markdown-link": "#4aa8c8",
"markdown-link-text": "#2f9bce",
"markdown-code": "#7fad00",
"markdown-block-quote": "#ed982e",
"markdown-emph": "#ed982e",
"markdown-strong": "#f07f72",
"markdown-horizontal-rule": "#d7cec0",
"markdown-list-item": "#4aa8c8",
"markdown-list-enumeration": "#2f9bce",
"markdown-image": "#4aa8c8",
"markdown-image-text": "#2f9bce",
"markdown-code-block": "#4aa8c8"
"syntax-keyword": "#ea9f41"
}
},
"dark": {
"seeds": {
"palette": {
"neutral": "#0f1419",
"ink": "#d6dae0",
"primary": "#3fb7e3",
"accent": "#f2856f",
"success": "#78d05c",
"warning": "#e4a75c",
"error": "#f58572",
"info": "#66c6f1",
"interactive": "#3fb7e3",
"diffAdd": "#59c57c",
"diffDelete": "#f58572"
},
"overrides": {
"background-base": "#0f1419",
"background-weak": "#18222c",
"background-strong": "#0b1015",
"background-stronger": "#080c10",
"surface-raised-base-hover": "#0f1419",
"border-weak-base": "#2b3440",
"border-weak-hover": "#323c49",
"border-weak-active": "#394454",
"border-weak-selected": "#415063",
"border-weak-disabled": "#0a0e12",
"border-weak-focus": "#374453",
"border-base": "#475367",
"border-hover": "#515f75",
"border-active": "#5d6b83",
"border-selected": "#687795",
"border-disabled": "#11161d",
"border-focus": "#56647c",
"border-strong-base": "#73819b",
"border-strong-hover": "#7f8da8",
"border-strong-active": "#8b99b5",
"border-strong-selected": "#98a6c3",
"border-strong-disabled": "#1b222c",
"border-strong-focus": "#8391ad",
"surface-diff-add-base": "#132f27",
"surface-diff-delete-base": "#361d20",
"surface-diff-hidden-base": "#1b2632",
"text-base": "#d6dae0",
"text-weak": "#a3adba",
"text-strong": "#fbfbfd",
"syntax-string": "#b1c74a",
"syntax-primitive": "#f2856f",
"syntax-property": "#3fb7e3",
"syntax-type": "#e4a75c",
"syntax-constant": "#66c6f1",
"syntax-info": "#66c6f1",
"markdown-heading": "#3fb7e3",
"markdown-text": "#d6dae0",
"markdown-link": "#3fb7e3",
"markdown-link-text": "#66c6f1",
"markdown-code": "#b1c74a",
"markdown-block-quote": "#e4a75c",
"markdown-emph": "#e4a75c",
"markdown-strong": "#f2856f",
"markdown-horizontal-rule": "#2b3542",
"markdown-list-item": "#3fb7e3",
"markdown-list-enumeration": "#66c6f1",
"markdown-image": "#3fb7e3",
"markdown-image-text": "#66c6f1",
"markdown-code-block": "#d6dae0"
"syntax-keyword": "#ffad66"
}
}
}

Some files were not shown because too many files have changed in this diff Show More