diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml new file mode 100644 index 00000000000..6d143a8a22f --- /dev/null +++ b/.github/workflows/storybook.yml @@ -0,0 +1,38 @@ +name: storybook + +on: + push: + branches: [dev] + paths: + - ".github/workflows/storybook.yml" + - "package.json" + - "bun.lock" + - "packages/storybook/**" + - "packages/ui/**" + pull_request: + branches: [dev] + paths: + - ".github/workflows/storybook.yml" + - "package.json" + - "bun.lock" + - "packages/storybook/**" + - "packages/ui/**" + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: storybook build + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Build Storybook + run: bun --cwd packages/storybook build diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 3497847a676..8380f7f719e 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -5,6 +5,11 @@ "options": {}, }, }, + "permission": { + "edit": { + "packages/opencode/migration/*": "deny", + }, + }, "mcp": {}, "tools": { "github-triage": false, diff --git a/bun.lock b/bun.lock index 5202b70d98b..bb13d0ea40a 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.22", + "version": "1.2.24", "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.24", "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.24", "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.24", "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.24", "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.24", "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.24", "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.24", "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.24", "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.24", "bin": { "opencode": "./bin/opencode", }, @@ -409,7 +409,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.22", + "version": "1.2.24", "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.24", "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.24", "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.24", "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.24", "dependencies": { "zod": "catalog:", }, @@ -535,7 +532,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.22", + "version": "1.2.24", "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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 2f14f9bf4ea..73491735f40 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index 90a449d5006..86147dc65d5 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -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() diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts index 76b1487e9ed..9454d683f02 100644 --- a/packages/app/e2e/projects/projects-close.spec.ts +++ b/packages/app/e2e/projects/projects-close.spec.ts @@ -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] }, ) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 002ac2114c4..2061a112849 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -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) => diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index e541738c590..68d99294996 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -7,7 +7,7 @@ import { openSharePopover, withSession, } from "../actions" -import { sessionItemSelector, inlineInputSelector, 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) }) }) diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index c2a8522eb05..f25e91a315e 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -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() diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts index 0dbc5f8b5a6..f07a8d3f111 100644 --- a/packages/app/e2e/utils.ts +++ b/packages/app/e2e/utils.ts @@ -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}` } diff --git a/packages/app/package.json b/packages/app/package.json index 51f9883a569..10ef17d1bf8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.2.22", + "version": "1.2.24", "description": "", "type": "module", "exports": { diff --git a/packages/app/public/oc-theme-preload.js b/packages/app/public/oc-theme-preload.js index f8c71049619..36fa5d726af 100644 --- a/packages/app/public/oc-theme-preload.js +++ b/packages/app/public/oc-theme-preload.js @@ -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" diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c27d6a977f2..9e5f12ee4c9 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -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 diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b7ac28ae1a7..40015db1be2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -424,6 +424,17 @@ export default function Layout(props: ParentProps) { return } + if ( + e.details?.type === "question.replied" || + e.details?.type === "question.rejected" || + e.details?.type === "permission.replied" + ) { + const props = e.details.properties as { sessionID: string } + const sessionKey = `${e.name}:${props.sessionID}` + dismissSessionAlert(sessionKey) + return + } + if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return const title = e.details.type === "permission.asked" @@ -1917,7 +1928,7 @@ export default function Layout(props: ParentProps) { return (
string | undefined + messagesReady: () => boolean + visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise + 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 +284,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 +460,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())) @@ -262,20 +485,49 @@ export default function Page() { return "main" }) - const activeMessage = createMemo(() => { - if (!store.messageId) return lastUserMessage() - const found = visibleUserMessages()?.find((m) => m.id === store.messageId) - return found ?? lastUserMessage() - }) const setActiveMessage = (message: UserMessage | undefined) => { + messageMark = scrollMark setStore("messageId", message?.id) } + const anchor = (id: string) => `message-${id}` + + const cursor = () => { + const root = scroller + if (!root) return store.messageId + + const box = root.getBoundingClientRect() + const line = box.top + 100 + const list = [...root.querySelectorAll("[data-message-id]")] + .map((el) => { + const id = el.dataset.messageId + if (!id) return + + const rect = el.getBoundingClientRect() + return { id, top: rect.top, bottom: rect.bottom } + }) + .filter((item): item is { id: string; top: number; bottom: number } => !!item) + + const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom) + const hit = shown.find((item) => item.top <= line && item.bottom >= line) + if (hit) return hit.id + + const near = [...shown].sort((a, b) => { + const da = Math.abs(a.top - line) + const db = Math.abs(b.top - line) + if (da !== db) return da - db + return a.top - b.top + })[0] + if (near) return near.id + + return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId + } + function navigateMessageByOffset(offset: number) { const msgs = visibleUserMessages() if (msgs.length === 0) return - const current = store.messageId + const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor() const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length const currentIndex = base === -1 ? msgs.length : base const targetIndex = currentIndex + offset @@ -348,6 +600,8 @@ export default function Page() { let dockHeight = 0 let scroller: HTMLDivElement | undefined let content: HTMLDivElement | undefined + let scrollMark = 0 + let messageMark = 0 const scrollGestureWindowMs = 250 @@ -392,6 +646,7 @@ export default function Page() { () => { setStore("messageId", undefined) setStore("changes", "session") + setUi("pendingMessage", undefined) }, { defer: true }, ), @@ -886,18 +1141,11 @@ 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 - setStore("messageId", id) - }, - }) 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 +1168,7 @@ export default function Page() { const resumeScroll = () => { setStore("messageId", undefined) - autoScroll.smoothScrollToBottom() + autoScroll.forceScrollToBottom() clearMessageHash() const el = scroller @@ -940,25 +1188,14 @@ export default function Page() { ), ) - createEffect( - on( - sessionKey, - () => { - scrollSpy.clear() - }, - { defer: true }, - ), - ) - - const anchor = (id: string) => `message-${id}` - const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) - scrollSpy.setContainer(el) - if (!el) return - scheduleScrollState(el) - scheduleHistoryFill() + if (el) scheduleScrollState(el) + } + + const markUserScroll = () => { + scrollMark += 1 } createResizeObserver( @@ -966,8 +1203,6 @@ export default function Page() { () => { const el = scroller if (el) scheduleScrollState(el) - scrollSpy.markDirty() - scheduleHistoryFill() }, ) @@ -982,45 +1217,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 +1226,15 @@ 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() }, ) @@ -1066,9 +1262,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 +1284,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(), @@ -1098,7 +1293,7 @@ export default function Page() {
- + { content = el @@ -1139,8 +1332,6 @@ export default function Page() { }} renderedUserMessages={historyWindow.renderedUserMessages()} anchor={anchor} - onRegisterMessage={scrollSpy.register} - onUnregisterMessage={scrollSpy.unregister} /> @@ -1205,6 +1396,7 @@ export default function Page() { reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} + reviewSnap={ui.reviewSnap} size={size} />
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 18a02993b69..93ea3d465c5 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 07df4305f0d..77643789d04 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) { ) return ( - + { scroll = el restoreScroll() diff --git a/packages/app/src/pages/session/history-window.test.ts b/packages/app/src/pages/session/history-window.test.ts deleted file mode 100644 index 4a9b894e275..00000000000 --- a/packages/app/src/pages/session/history-window.test.ts +++ /dev/null @@ -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, - ) - }) -}) diff --git a/packages/app/src/pages/session/history-window.ts b/packages/app/src/pages/session/history-window.ts deleted file mode 100644 index e3ef20f13d4..00000000000 --- a/packages/app/src/pages/session/history-window.ts +++ /dev/null @@ -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 - 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, - } -} diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index e93ca11a36b..6463e7cbbe3 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -1,31 +1,28 @@ -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 { Spinner } from "@opencode-ai/ui/spinner" 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 +34,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 +111,6 @@ function createTimelineStaging(input: TimelineStageInput) { completedSession: "", count: 0, }) - const [readySession, setReadySession] = createSignal("") - let active = "" const stagedCount = createMemo(() => { const total = input.messages().length @@ -142,46 +135,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 +161,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 +178,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: { @@ -227,11 +193,9 @@ export function MessageTimeline(props: { onAutoScrollHandleScroll: () => void onMarkScrollGesture: (target?: EventTarget | null) => void hasScrollGesture: () => boolean - isDesktop: boolean - onScrollSpyScroll: () => void + onUserScroll: () => void onTurnBackfillScroll: () => void onAutoScrollInteraction: (event: MouseEvent) => void - onPreserveScrollAnchor: (target: HTMLElement) => void centered: boolean setContentRef: (el: HTMLDivElement) => void turnStart: number @@ -240,25 +204,18 @@ export function MessageTimeline(props: { onLoadEarlier: () => void renderedUserMessages: UserMessage[] anchor: (id: string) => string - onRegisterMessage: (el: HTMLDivElement, id: string) => void - onUnregisterMessage: (id: string) => void }) { 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 +228,62 @@ 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 working = createMemo(() => !!pending() || sessionStatus().type !== "idle") + + const [slot, setSlot] = createStore({ + open: false, + show: false, + fade: false, + }) + + let f: number | undefined + const clear = () => { + if (f !== undefined) window.clearTimeout(f) + f = undefined + } + + onCleanup(clear) + createEffect( + on( + working, + (on, prev) => { + clear() + if (on) { + setSlot({ open: true, show: true, fade: false }) + return + } + if (prev) { + setSlot({ open: false, show: true, fade: true }) + f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) + return + } + setSlot({ open: false, show: false, fade: false }) + }, + { defer: true }, + ), + ) 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 +291,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 +301,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([sessionID]) + + const byParent = new Map() + for (const item of draft.session) { + const parentID = item.parentID + if (!parentID) continue + const existing = byParent.get(parentID) + if (existing) { + existing.push(item.id) + continue + } + byParent.set(parentID, [item.id]) + } + + const stack = [sessionID] + while (stack.length) { + const parentID = stack.pop() + if (!parentID) continue + + const children = byParent.get(parentID) + if (!children) continue + + for (const child of children) { + if (removed.has(child)) continue + removed.add(child) + stack.push(child) + } + } + + draft.session = draft.session.filter((s) => !removed.has(s.id)) + }), + ) + + navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) + return true + } + + const navigateParent = () => { + const id = parentID() + if (!id) return + navigate(`/${params.dir}/session/${id}`) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: name() })} + +
+
+ + +
+
+
+ ) + } return (
- { const root = e.currentTarget @@ -381,44 +564,166 @@ 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() if (!props.hasScrollGesture()) return + props.onUserScroll() props.onAutoScrollHandleScroll() 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", }} > -
+
+ +
+
+
+ + + +
+ + + + {titleValue()} + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" + style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={closeTitleEditor} + /> + + +
+
+ + {(id) => ( +
+ + setTitle("menuOpen", open)} + > + + + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > + { + setTitle("pendingRename", true) + setTitle("menuOpen", false) + }} + > + {language.t("common.rename")} + + void archiveSession(id())}> + {language.t("common.archive")} + + + dialog.show(() => )} + > + {language.t("common.delete")} + + + + +
+ )} +
+
+
+
+
{(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,23 +753,16 @@ 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 (
{ - props.onRegisterMessage(el, messageID) - onCleanup(() => props.onUnregisterMessage(messageID)) - }} 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, }} > 0}> @@ -517,7 +806,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()} diff --git a/packages/app/src/pages/session/scroll-spy.test.ts b/packages/app/src/pages/session/scroll-spy.test.ts deleted file mode 100644 index f3e6775cb48..00000000000 --- a/packages/app/src/pages/session/scroll-spy.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { createScrollSpy, pickOffsetId, pickVisibleId } from "./scroll-spy" - -const rect = (top: number, height = 80): DOMRect => - ({ - x: 0, - y: top, - top, - left: 0, - right: 800, - bottom: top + height, - width: 800, - height, - toJSON: () => ({}), - }) as DOMRect - -const setRect = (el: Element, top: number, height = 80) => { - Object.defineProperty(el, "getBoundingClientRect", { - configurable: true, - value: () => rect(top, height), - }) -} - -describe("pickVisibleId", () => { - test("prefers higher intersection ratio", () => { - const id = pickVisibleId( - [ - { id: "a", ratio: 0.2, top: 100 }, - { id: "b", ratio: 0.8, top: 300 }, - ], - 120, - ) - - expect(id).toBe("b") - }) - - test("breaks ratio ties by nearest line", () => { - const id = pickVisibleId( - [ - { id: "a", ratio: 0.5, top: 90 }, - { id: "b", ratio: 0.5, top: 140 }, - ], - 130, - ) - - expect(id).toBe("b") - }) -}) - -describe("pickOffsetId", () => { - test("uses binary search cutoff", () => { - const id = pickOffsetId( - [ - { id: "a", top: 0 }, - { id: "b", top: 200 }, - { id: "c", top: 400 }, - ], - 350, - ) - - expect(id).toBe("b") - }) -}) - -describe("createScrollSpy fallback", () => { - test("tracks active id from offsets and dirty refresh", () => { - const active: string[] = [] - const root = document.createElement("div") as HTMLDivElement - const one = document.createElement("div") - const two = document.createElement("div") - const three = document.createElement("div") - - root.append(one, two, three) - document.body.append(root) - - Object.defineProperty(root, "scrollTop", { configurable: true, writable: true, value: 250 }) - setRect(root, 0, 800) - setRect(one, -250) - setRect(two, -50) - setRect(three, 150) - - const queue: FrameRequestCallback[] = [] - const flush = () => { - const run = [...queue] - queue.length = 0 - for (const cb of run) cb(0) - } - - const spy = createScrollSpy({ - onActive: (id) => active.push(id), - raf: (cb) => (queue.push(cb), queue.length), - caf: () => {}, - IntersectionObserver: undefined, - ResizeObserver: undefined, - MutationObserver: undefined, - }) - - spy.setContainer(root) - spy.register(one, "a") - spy.register(two, "b") - spy.register(three, "c") - spy.onScroll() - flush() - - expect(spy.getActiveId()).toBe("b") - expect(active.at(-1)).toBe("b") - - root.scrollTop = 450 - setRect(one, -450) - setRect(two, -250) - setRect(three, -50) - spy.onScroll() - flush() - expect(spy.getActiveId()).toBe("c") - - root.scrollTop = 250 - setRect(one, -250) - setRect(two, 250) - setRect(three, 150) - spy.markDirty() - spy.onScroll() - flush() - expect(spy.getActiveId()).toBe("a") - - spy.destroy() - }) -}) diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts deleted file mode 100644 index 6ef4c844c41..00000000000 --- a/packages/app/src/pages/session/scroll-spy.ts +++ /dev/null @@ -1,275 +0,0 @@ -type Visible = { - id: string - ratio: number - top: number -} - -type Offset = { - id: string - top: number -} - -type Input = { - onActive: (id: string) => void - raf?: (cb: FrameRequestCallback) => number - caf?: (id: number) => void - IntersectionObserver?: typeof globalThis.IntersectionObserver - ResizeObserver?: typeof globalThis.ResizeObserver - MutationObserver?: typeof globalThis.MutationObserver -} - -export const pickVisibleId = (list: Visible[], line: number) => { - if (list.length === 0) return - - const sorted = [...list].sort((a, b) => { - if (b.ratio !== a.ratio) return b.ratio - a.ratio - - const da = Math.abs(a.top - line) - const db = Math.abs(b.top - line) - if (da !== db) return da - db - - return a.top - b.top - }) - - return sorted[0]?.id -} - -export const pickOffsetId = (list: Offset[], cutoff: number) => { - if (list.length === 0) return - - let lo = 0 - let hi = list.length - 1 - let out = 0 - - while (lo <= hi) { - const mid = (lo + hi) >> 1 - const top = list[mid]?.top - if (top === undefined) break - - if (top <= cutoff) { - out = mid - lo = mid + 1 - continue - } - - hi = mid - 1 - } - - return list[out]?.id -} - -export const createScrollSpy = (input: Input) => { - const raf = input.raf ?? requestAnimationFrame - const caf = input.caf ?? cancelAnimationFrame - const CtorIO = input.IntersectionObserver ?? globalThis.IntersectionObserver - const CtorRO = input.ResizeObserver ?? globalThis.ResizeObserver - const CtorMO = input.MutationObserver ?? globalThis.MutationObserver - - let root: HTMLDivElement | undefined - let io: IntersectionObserver | undefined - let ro: ResizeObserver | undefined - let mo: MutationObserver | undefined - let frame: number | undefined - let active: string | undefined - let dirty = true - - const node = new Map() - const id = new WeakMap() - const visible = new Map() - let offset: Offset[] = [] - - const schedule = () => { - if (frame !== undefined) return - frame = raf(() => { - frame = undefined - update() - }) - } - - const refreshOffset = () => { - const el = root - if (!el) { - offset = [] - dirty = false - return - } - - const base = el.getBoundingClientRect().top - offset = [...node].map(([next, item]) => ({ - id: next, - top: item.getBoundingClientRect().top - base + el.scrollTop, - })) - offset.sort((a, b) => a.top - b.top) - dirty = false - } - - const update = () => { - const el = root - if (!el) return - - const line = el.getBoundingClientRect().top + 100 - const next = - pickVisibleId( - [...visible].map(([k, v]) => ({ - id: k, - ratio: v.ratio, - top: v.top, - })), - line, - ) ?? - (() => { - if (dirty) refreshOffset() - return pickOffsetId(offset, el.scrollTop + 100) - })() - - if (!next || next === active) return - active = next - input.onActive(next) - } - - const observe = () => { - const el = root - if (!el) return - - io?.disconnect() - io = undefined - if (CtorIO) { - try { - io = new CtorIO( - (entries) => { - for (const entry of entries) { - const item = entry.target - if (!(item instanceof HTMLElement)) continue - const key = id.get(item) - if (!key) continue - - if (!entry.isIntersecting || entry.intersectionRatio <= 0) { - visible.delete(key) - continue - } - - visible.set(key, { - ratio: entry.intersectionRatio, - top: entry.boundingClientRect.top, - }) - } - - schedule() - }, - { - root: el, - threshold: [0, 0.25, 0.5, 0.75, 1], - }, - ) - } catch { - io = undefined - } - } - - if (io) { - for (const item of node.values()) io.observe(item) - } - - ro?.disconnect() - ro = undefined - if (CtorRO) { - ro = new CtorRO(() => { - dirty = true - schedule() - }) - ro.observe(el) - for (const item of node.values()) ro.observe(item) - } - - mo?.disconnect() - mo = undefined - if (CtorMO) { - mo = new CtorMO(() => { - dirty = true - schedule() - }) - mo.observe(el, { subtree: true, childList: true, characterData: true }) - } - - dirty = true - schedule() - } - - const setContainer = (el?: HTMLDivElement) => { - if (root === el) return - - root = el - visible.clear() - active = undefined - observe() - } - - const register = (el: HTMLElement, key: string) => { - const prev = node.get(key) - if (prev && prev !== el) { - io?.unobserve(prev) - ro?.unobserve(prev) - } - - node.set(key, el) - id.set(el, key) - if (io) io.observe(el) - if (ro) ro.observe(el) - dirty = true - schedule() - } - - const unregister = (key: string) => { - const item = node.get(key) - if (!item) return - - io?.unobserve(item) - ro?.unobserve(item) - node.delete(key) - visible.delete(key) - dirty = true - schedule() - } - - const markDirty = () => { - dirty = true - schedule() - } - - const clear = () => { - for (const item of node.values()) { - io?.unobserve(item) - ro?.unobserve(item) - } - - node.clear() - visible.clear() - offset = [] - active = undefined - dirty = true - } - - const destroy = () => { - if (frame !== undefined) caf(frame) - frame = undefined - clear() - io?.disconnect() - ro?.disconnect() - mo?.disconnect() - io = undefined - ro = undefined - mo = undefined - root = undefined - } - - return { - setContainer, - register, - unregister, - onScroll: schedule, - markDirty, - clear, - destroy, - getActiveId: () => active, - } -} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index a5e067c6f05..590f5b6d9ba 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -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() }} > diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx deleted file mode 100644 index 32412f0a7fd..00000000000 --- a/packages/app/src/pages/session/session-timeline-header.tsx +++ /dev/null @@ -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([sessionID]) - const byParent = new Map() - - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [sessionID] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((item) => !removed.has(item.id)) - }), - ) - - navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id) - return true - } - - const navigateParent = () => { - const id = props.parentID() - if (!id) return - navigate(`/${params.dir}/session/${id}`) - } - - function DialogDeleteSession(input: { sessionID: string }) { - const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new")) - - const handleDelete = async () => { - await deleteSession(input.sessionID) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: name() })} - -
-
- - -
-
-
- ) - } - - return ( - -
{ - headerRef = el - el.style.opacity = "0" - }} - class="pointer-events-none absolute inset-x-0 top-0 z-30" - > -
-
-
- -
- -
-
- - - - - {headerText.value} - - - {headerText.prev} - - - - } - > - { - titleRef = el - }} - value={title.draft} - disabled={title.saving} - class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]" - style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }} - onInput={(event) => setTitle("draft", event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void saveTitleEditor() - return - } - if (event.key === "Escape") { - event.preventDefault() - closeTitleEditor() - } - }} - onBlur={closeTitleEditor} - /> - - -
- - {(id) => ( -
- - setTitle("menuOpen", open)} - > - - - { - if (!title.pendingRename) return - event.preventDefault() - setTitle("pendingRename", false) - openTitleEditor() - }} - > - { - setTitle("pendingRename", true) - setTitle("menuOpen", false) - }} - > - {language.t("common.rename")} - - void archiveSession(id())}> - {language.t("common.archive")} - - - dialog.show(() => )}> - {language.t("common.delete")} - - - - -
- )} -
-
-
-
-
- ) -} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 278a1ba6e56..1ea6a302b95 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,4 +1,5 @@ import type { UserMessage } from "@opencode-ai/sdk/v2" +import { useLocation, useNavigate } from "@solidjs/router" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { 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 @@ -25,14 +26,40 @@ export const useSessionHashScroll = (input: { const messageById = createMemo(() => new Map(visibleUserMessages().map((m) => [m.id, m]))) const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" + let clearing = false + + const location = useLocation() + const navigate = useNavigate() + + const frames = new Set() + const queue = (fn: () => void) => { + const id = requestAnimationFrame(() => { + frames.delete(id) + fn() + }) + frames.add(id) + } + const cancel = () => { + for (const id of frames) cancelAnimationFrame(id) + frames.clear() + } const clearMessageHash = () => { - if (!window.location.hash) return - window.history.replaceState(null, "", window.location.pathname + window.location.search) + cancel() + input.consumePendingMessage(input.sessionKey()) + if (input.pendingMessage()) input.setPendingMessage(undefined) + if (!location.hash) return + clearing = true + navigate(location.pathname + location.search, { replace: true }) } const updateHash = (id: string) => { - window.history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${input.anchor(id)}`) + const hash = `#${input.anchor(id)}` + if (location.hash === hash) return + clearing = false + navigate(location.pathname + location.search + hash, { + replace: true, + }) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -41,65 +68,51 @@ 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 seek = (id: string, behavior: ScrollBehavior, left = 4): boolean => { + const el = document.getElementById(input.anchor(id)) + if (el) return scrollToElement(el, behavior) + if (left <= 0) return false + queue(() => { + seek(id, behavior, left - 1) + }) + return false + } + const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + cancel() if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 if (index !== -1 && index < input.turnStart()) { input.setTurnStart(index) - requestAnimationFrame(() => { - const el = document.getElementById(input.anchor(message.id)) - if (!el) { - requestAnimationFrame(() => { - const next = document.getElementById(input.anchor(message.id)) - if (!next) return - scrollToElement(next, behavior) - }) - return - } - scrollToElement(el, behavior) + queue(() => { + seek(message.id, behavior) }) updateHash(message.id) return } - const el = document.getElementById(input.anchor(message.id)) - if (!el) { - updateHash(message.id) - requestAnimationFrame(() => { - const next = document.getElementById(input.anchor(message.id)) - if (!next) return - if (!scrollToElement(next, behavior)) return - }) - return - } - if (scrollToElement(el, behavior)) { + if (seek(message.id, behavior)) { updateHash(message.id) return } - requestAnimationFrame(() => { - const next = document.getElementById(input.anchor(message.id)) - if (!next) return - if (!scrollToElement(next, behavior)) return - }) updateHash(message.id) } 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,28 +136,17 @@ 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(() => { + const hash = location.hash + if (!hash) clearing = false if (!input.sessionID() || !input.messagesReady()) return - requestAnimationFrame(() => applyHash("auto")) + cancel() + queue(() => applyHash("auto")) }) createEffect(() => { @@ -166,17 +168,29 @@ export const useSessionHashScroll = (input: { } } + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) if (!targetId) return - if (input.currentMessageId() === targetId) return + const pending = input.pendingMessage() === targetId const msg = messageById().get(targetId) if (!msg) return - if (input.pendingMessage() === targetId) input.setPendingMessage(undefined) + if (pending) input.setPendingMessage(undefined) + if (input.currentMessageId() === targetId && !pending) return + input.autoScroll.pause() - requestAnimationFrame(() => scrollToMessage(msg, "auto")) + cancel() + queue(() => scrollToMessage(msg, "auto")) + }) + + onMount(() => { + if (typeof window !== "undefined" && "scrollRestoration" in window.history) { + window.history.scrollRestoration = "manual" + } }) + onCleanup(cancel) + return { clearMessageHash, scrollToMessage, diff --git a/packages/app/src/theme-preload.test.ts b/packages/app/src/theme-preload.test.ts new file mode 100644 index 00000000000..00d7da23948 --- /dev/null +++ b/packages/app/src/theme-preload.test.ts @@ -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;") + }) +}) diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 2a2c349b755..673acd224d2 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -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() + }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 91c504742a9..bee2f3e7d1f 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -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 = { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 62474711f08..2cbc8f75756 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.2.22", + "version": "1.2.24", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index b8cf0810475..209a0e2df36 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.2.22", + "version": "1.2.24", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index ed4cfed17a7..377ab97cf61 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.2.22", + "version": "1.2.24", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index c41e66c051b..9a383f19fbe 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.2.22", + "version": "1.2.24", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 2b9ce92da4c..45fa7355f8a 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.2.22", + "version": "1.2.24", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8663cc8d581..b028c840bae 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.2.22", + "version": "1.2.24", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 9807922a2cd..1b14e3f142e 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.2.22", + "version": "1.2.24", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index ddc61ca4c6c..44cdeb9d941 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.2.22" +version = "1.2.24" 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.24/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.24/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.24/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.24/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.24/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 1bbb1dffde4..7b64af1f99e 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.2.22", + "version": "1.2.24", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7a25100b0b7..c560315d33f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.2.22", + "version": "1.2.24", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 61bc609bb7c..5c6dd11f997 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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) } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 3304d6be6a6..e939b831d5a 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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(() => ) }, }, + ...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI + ? [ + { + title: "Manage workspaces", + value: "workspace.list", + category: "Workspace", + suggested: true, + slash: { + name: "workspaces", + }, + onSelect: () => { + dialog.replace(() => ) + }, + }, + ] + : []), { title: "New session", suggested: route.data.type === "session", diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx new file mode 100644 index 00000000000..a25b20505c4 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-workspace-list.tsx @@ -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 + route: ReturnType + sdk: ReturnType + sync: ReturnType + toast: ReturnType + 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 }) { + const dialog = useDialog() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const [creating, setCreating] = createSignal() + + 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 ( + { + 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() + const [counts, setCounts] = createSignal>({}) + + 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(() => ) + return + } + route.navigate({ + type: "home", + }) + dialog.clear() + return + } + const count = counts()[workspaceID] + if (count && count > 0) { + dialog.replace(() => ) + 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(() => ) + 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 ( + { + setToDelete(undefined) + }} + onSelect={(option) => { + setToDelete(undefined) + if (option.value === "__create__") { + dialog.replace(() => 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() + }, + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d63c248fb83..77577b2a0ef 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -539,12 +539,25 @@ export function Prompt(props: PromptProps) { promptModelWarning() return } - const sessionID = props.sessionID - ? props.sessionID - : await (async () => { - const sessionID = await sdk.client.session.create({}).then((x) => x.data!.id) - return sessionID - })() + + let sessionID = props.sessionID + if (sessionID == null) { + const res = await sdk.client.session.create({}) + + if (res.error) { + console.log("Creating a session failed:", res.error) + + toast.show({ + message: "Creating a session failed. Open console for more details.", + variant: "error", + }) + + return + } + + sessionID = res.data.id + } + const messageID = Identifier.ascending("message") let inputText = store.prompt.input diff --git a/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx new file mode 100644 index 00000000000..326f094a56f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/workspace/dialog-session-list.tsx @@ -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() + 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 ? : undefined, + } + }) + }) + + onMount(() => { + dialog.setSize("large") + }) + + return ( + { + 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(() => ) + }, + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 7fa7e05c3d2..2403a4e938b 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -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 @@ -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 - if (props.events) { - const unsub = props.events.on(handleEvent) - onCleanup(unsub) - return - } + 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 }) - // 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) { + if (ctrl.signal.aborted) break + handleEvent(event) + } - for await (const event of events.stream) { - handleEvent(event) + if (timer) clearTimeout(timer) + if (queue.length > 0) flush() } + })().catch(() => {}) + } - // Flush any remaining events - if (timer) clearTimeout(timer) - if (queue.length > 0) { - flush() - } + onMount(() => { + if (props.events) { + const unsub = props.events.on(handleEvent) + onCleanup(unsub) + } 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, + } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 269ed7ae0bd..3b296a927aa 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 0c5ea9a8572..49b2d61091f 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -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 }) => { @@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor; cost: Acces ) } +const WorkspaceInfo = (props: { workspace: Accessor }) => { + const { theme } = useTheme() + return ( + + + {props.workspace()} + + + ) +} + 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() { - - Subagent session - + {Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? ( + + + Subagent session + + + + ) : ( + + Subagent session + + )} + @@ -124,7 +154,14 @@ export function Header() { - + {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> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index d3a4ff81e01..5358b61ef33 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -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) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 14a9c88731f..6e787c7afdd 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -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 }) + }, } } diff --git a/packages/opencode/src/cli/cmd/tui/worker.ts b/packages/opencode/src/cli/cmd/tui/worker.ts index 4452d6d764a..f8dcee78a05 100644 --- a/packages/opencode/src/cli/cmd/tui/worker.ts +++ b/packages/opencode/src/cli/cmd/tui/worker.ts @@ -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() diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 6b2b16c67be..7c48e99f47f 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -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"] diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index e09fbc97fe3..405c6728537 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1130,7 +1130,30 @@ export namespace LSPServer { export const JDTLS: Info = { id: "jdtls", - root: NearestRoot(["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"]), + root: async (file) => { + // Without exclusions, NearestRoot defaults to instance directory so we can't + // distinguish between a) no project found and b) project found at instance dir. + // So we can't choose the root from (potential) monorepo markers first. + // Look for potential subproject markers first while excluding potential monorepo markers. + const settingsMarkers = ["settings.gradle", "settings.gradle.kts"] + const gradleMarkers = ["gradlew", "gradlew.bat"] + const exclusionsForMonorepos = gradleMarkers.concat(settingsMarkers) + + const [projectRoot, wrapperRoot, settingsRoot] = await Promise.all([ + NearestRoot( + ["pom.xml", "build.gradle", "build.gradle.kts", ".project", ".classpath"], + exclusionsForMonorepos, + )(file), + NearestRoot(gradleMarkers, settingsMarkers)(file), + NearestRoot(settingsMarkers)(file), + ]) + + // If projectRoot is undefined we know we are in a monorepo or no project at all. + // So can safely fall through to the other roots + if (projectRoot) return projectRoot + if (wrapperRoot) return wrapperRoot + if (settingsRoot) return settingsRoot + }, extensions: [".java"], async spawn(root) { const java = which("java") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b4836ae047d..09035d272c9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -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 } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 6980be05188..471da03cbce 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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) => [ diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts index c512a45909d..86e08a79284 100644 --- a/packages/opencode/test/provider/gitlab-duo.test.ts +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -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) => { diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 2329846351c..512819a6577 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -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", () => { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index cb6640b5d3f..3f1f6af95f6 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.2.22", + "version": "1.2.24", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 19d50e85cd5..7281174c0f3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.2.22", + "version": "1.2.24", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/client.ts b/packages/sdk/js/src/v2/client.ts index 8685be52d6a..ad956dd4b3c 100644 --- a/packages/sdk/js/src/v2/client.ts +++ b/packages/sdk/js/src/v2/client.ts @@ -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 }) } diff --git a/packages/slack/package.json b/packages/slack/package.json index b5c02a45ff4..4ed7386e8a9 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.2.22", + "version": "1.2.24", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4022deb4aed..06c699f24ea 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.2.22", + "version": "1.2.24", "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", diff --git a/packages/ui/src/components/animated-number.css b/packages/ui/src/components/animated-number.css index b69ce65084b..022b347e968 100644 --- a/packages/ui/src/components/animated-number.css +++ b/packages/ui/src/components/animated-number.css @@ -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)); } diff --git a/packages/ui/src/components/animated-number.tsx b/packages/ui/src/components/animated-number.tsx index dfe368b8bbf..b5fceba2563 100644 --- a/packages/ui/src/components/animated-number.tsx +++ b/packages/ui/src/components/animated-number.tsx @@ -1,7 +1,7 @@ import { For, Index, createEffect, createMemo, createSignal, on } from "solid-js" const TRACK = Array.from({ length: 30 }, (_, index) => index % 10) -const DURATION = 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> ) diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index ad25bef32b4..1dbfce26ec5 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -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; diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 3210b487019..4ad91824da1 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -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} /> ) } diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 1a86338bdc8..bab2c4f9269 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -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; + } +} diff --git a/packages/ui/src/components/context-tool-results.tsx b/packages/ui/src/components/context-tool-results.tsx deleted file mode 100644 index a0d9311de45..00000000000 --- a/packages/ui/src/components/context-tool-results.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx deleted file mode 100644 index c8ea6f3b3a9..00000000000 --- a/packages/ui/src/components/grow-box.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index 73d83f7d72c..ff5d1df007a 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -244,6 +244,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { event.stopPropagation() if (e.key === "Escape") { event.preventDefault() + e.currentTarget.blur() split.onCancel() return } diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9a6784d7025..8fc7090133e 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -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; + [data-slot="message-part-title-filename"] { + /* No text-transform - preserve original filename casing */ 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; + [data-slot="message-part-path"] { + display: flex; + flex-grow: 1; + min-width: 0; + font-weight: var(--font-weight-regular); } - [data-slot="message-part-directory-inline"] { + [data-slot="message-part-directory"] { color: var(--text-weak); - min-width: 0; - max-width: min(48vw, 36ch); text-overflow: ellipsis; overflow: hidden; white-space: nowrap; direction: rtl; text-align: left; } + + [data-slot="message-part-filename"] { + color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="message-part-actions"] { + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + } } [data-component="edit-content"] { @@ -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-slot="collapsible-arrow"] { + color: var(--icon-weaker); } } -[data-component="context-tool-step"] { - width: 100%; - min-width: 0; - padding-left: 12px; -} - -[data-component="context-tool-expanded-list"] { +[data-component="context-tool-group-list"] { + padding: 6px 0 4px 0; 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; + gap: 2px; - &::-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; + [data-slot="context-tool-group-item"] { min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - font-size: var(--font-size-base); - color: var(--text-base); - opacity: 0.75; + padding: 6px 0; } } -[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"] { - 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-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; - } } diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d8212115929..45b174e2b84 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -1,6 +1,18 @@ -import { Component, createEffect, createMemo, createSignal, For, Match, on, Show, Switch, type JSX } from "solid-js" +import { + Component, + createEffect, + createMemo, + createSignal, + For, + Match, + onMount, + Show, + Switch, + onCleanup, + Index, + type JSX, +} from "solid-js" import stripAnsi from "strip-ansi" -import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" import { AgentPart, @@ -20,10 +32,11 @@ import { useData } from "../context" import { useFileComponent } from "../context/file" import { useDialog } from "../context/dialog" import { type UiI18n, useI18n } from "../context/i18n" -import { GenericTool, ToolCall } from "./basic-tool" +import { BasicTool, GenericTool } from "./basic-tool" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Card } from "./card" +import { Collapsible } from "./collapsible" import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" @@ -35,12 +48,43 @@ import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "./text-shimmer" -import { list } from "./text-utils" -import { GrowBox } from "./grow-box" -import { COLLAPSIBLE_SPRING } from "./motion" -import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils" -import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results" -import { ShellRollingResults } from "./shell-rolling-results" +import { AnimatedCountList } from "./tool-count-summary" +import { ToolStatusTitle } from "./tool-status-title" +import { animate } from "motion" +import { useLocation } from "@solidjs/router" + +function ShellSubmessage(props: { text: string; animate?: boolean }) { + let widthRef: HTMLSpanElement | undefined + let valueRef: HTMLSpanElement | undefined + + onMount(() => { + if (!props.animate) return + requestAnimationFrame(() => { + if (widthRef) { + animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 }) + } + if (valueRef) { + animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] }) + } + }) + }) + + return ( + <span data-component="shell-submessage"> + <span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}> + <span data-slot="basic-tool-tool-subtitle"> + <span + ref={valueRef} + data-slot="shell-submessage-value" + style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined} + > + {props.text} + </span> + </span> + </span> + </span> + ) +} interface Diagnostic { range: { @@ -81,22 +125,64 @@ function DiagnosticsDisplay(props: { diagnostics: Diagnostic[] }): JSX.Element { ) } +export interface MessageProps { + message: MessageType + parts: PartType[] + showAssistantCopyPartID?: string | null + interrupted?: boolean + queued?: boolean + showReasoningSummaries?: boolean +} + export interface MessagePartProps { part: PartType message: MessageType hideDetails?: boolean defaultOpen?: boolean showAssistantCopyPartID?: string | null - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element - animate?: boolean - working?: boolean + turnDurationMs?: number } export type PartComponent = Component<MessagePartProps> export const PART_MAPPING: Record<string, PartComponent | undefined> = {} +const TEXT_RENDER_THROTTLE_MS = 100 + +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 +} + function relativizeProjectPath(path: string, directory?: string) { if (!path) return "" if (!directory) return path @@ -228,8 +314,7 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { case "skill": return { icon: "brain", - title: i18n.t("ui.tool.skill"), - subtitle: typeof input.name === "string" ? input.name : undefined, + title: input.name || "skill", } default: return { @@ -254,22 +339,105 @@ function urls(text: string | undefined) { const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite", "todoread"]) -function createGroupOpenState() { - const [state, setState] = createStore<Record<string, boolean>>({}) - const read = (key?: string, collapse?: boolean) => { - if (!key) return true - const value = state[key] - if (value !== undefined) return value - return !collapse - } - const controlled = (key?: string) => { - if (!key) return false - return state[key] !== undefined +function list<T>(value: T[] | undefined | null, fallback: T[]) { + if (Array.isArray(value)) return value + return fallback +} + +function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((x, i) => x === b[i]) +} + +type PartRef = { + messageID: string + partID: string +} + +type PartGroup = + | { + key: string + type: "part" + ref: PartRef + } + | { + key: string + type: "context" + refs: PartRef[] + } + +function sameRef(a: PartRef, b: PartRef) { + return a.messageID === b.messageID && a.partID === b.partID +} + +function sameGroup(a: PartGroup, b: PartGroup) { + if (a === b) return true + if (a.key !== b.key) return false + if (a.type !== b.type) return false + if (a.type === "part") { + if (b.type !== "part") return false + return sameRef(a.ref, b.ref) } - const write = (key: string, value: boolean) => { - setState(key, value) + if (b.type !== "context") return false + if (a.refs.length !== b.refs.length) return false + return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!)) +} + +function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) { + if (a === b) return true + if (!a || !b) return false + if (a.length !== b.length) return false + return a.every((item, i) => sameGroup(item, b[i]!)) +} + +function groupParts(parts: { messageID: string; part: PartType }[]) { + const result: PartGroup[] = [] + let start = -1 + + const flush = (end: number) => { + if (start < 0) return + const first = parts[start] + const last = parts[end] + if (!first || !last) { + start = -1 + return + } + result.push({ + key: `context:${first.part.id}`, + type: "context", + refs: parts.slice(start, end + 1).map((item) => ({ + messageID: item.messageID, + partID: item.part.id, + })), + }) + start = -1 } - return { read, controlled, write } + + parts.forEach((item, index) => { + if (isContextGroupTool(item.part)) { + if (start < 0) start = index + return + } + + flush(index - 1) + result.push({ + key: `part:${item.messageID}:${item.part.id}`, + type: "part", + ref: { + messageID: item.messageID, + partID: item.part.id, + }, + }) + }) + + flush(parts.length - 1) + return result +} + +function partByID(parts: readonly PartType[], partID: string) { + return parts.find((part) => part.id === partID) } function renderable(part: PartType, showReasoningSummaries = true) { @@ -285,8 +453,7 @@ function renderable(part: PartType, showReasoningSummaries = true) { function toolDefaultOpen(tool: string, shell = false, edit = false) { if (tool === "bash") return shell - if (tool === "edit" || tool === "write") return edit - if (tool === "apply_patch") return false + if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit } function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -294,323 +461,98 @@ function partDefaultOpen(part: PartType, shell = false, edit = false) { return toolDefaultOpen(part.tool, shell, edit) } -function PartGrow(props: { - children: JSX.Element - animate?: boolean - animateToggle?: boolean - gap?: number - fade?: boolean - edge?: boolean - edgeHeight?: number - edgeOpacity?: number - edgeIdle?: number - edgeFade?: number - edgeRise?: number - grow?: boolean - watch?: boolean - open?: boolean - spring?: import("./motion").SpringConfig - toggleSpring?: import("./motion").SpringConfig -}) { - return ( - <GrowBox - animate={props.animate !== false} - animateToggle={props.animateToggle} - fade={props.fade} - edge={props.edge} - edgeHeight={props.edgeHeight} - edgeOpacity={props.edgeOpacity} - edgeIdle={props.edgeIdle} - edgeFade={props.edgeFade} - edgeRise={props.edgeRise} - gap={props.gap} - grow={props.grow} - watch={props.watch} - open={props.open} - spring={props.spring} - toggleSpring={props.toggleSpring} - slot="assistant-part-grow" - > - {props.children} - </GrowBox> - ) -} - export function AssistantParts(props: { messages: AssistantMessage[] showAssistantCopyPartID?: string | null - showTurnDiffSummary?: boolean - turnDiffSummary?: () => JSX.Element + turnDurationMs?: number working?: boolean showReasoningSummaries?: boolean shellToolDefaultOpen?: boolean editToolDefaultOpen?: boolean - animate?: boolean }) { const data = useData() const emptyParts: PartType[] = [] - const groupState = createGroupOpenState() - const grouped = createMemo(() => { - const keys: string[] = [] - const items: Record< - string, - | { - type: "part" - part: PartType - message: AssistantMessage - context?: boolean - groupKey?: string - afterTool?: boolean - groupTail?: boolean - groupParts?: { part: ToolPart; message: AssistantMessage }[] - } - | { - type: "context" - groupKey: string - parts: { part: ToolPart; message: AssistantMessage }[] - tail: boolean - afterTool: boolean - } - > = {} - const push = (key: string, item: (typeof items)[string]) => { - keys.push(key) - items[key] = item - } - const id = (part: PartType) => { - if (part.type === "tool") return part.callID || part.id - return part.id - } - const parts = props.messages.flatMap((message) => - list(data.store.part?.[message.id], emptyParts) - .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) - .map((part) => ({ message, part })), - ) - - let start = -1 - - const flush = (end: number, tail: boolean, afterTool: boolean) => { - if (start < 0) return - const group = parts - .slice(start, end + 1) - .filter((entry): entry is { part: ToolPart; message: AssistantMessage } => isContextGroupTool(entry.part)) - if (!group.length) { - start = -1 - return - } - const groupKey = `context:${group[0].message.id}:${id(group[0].part)}` - push(groupKey, { - type: "context", - groupKey, - parts: group, - tail, - afterTool, - }) - group.forEach((entry) => { - push(`part:${entry.message.id}:${id(entry.part)}`, { - type: "part", - part: entry.part, - message: entry.message, - context: true, - groupKey, - afterTool, - groupTail: tail, - groupParts: group, - }) - }) - start = -1 - } - parts.forEach((item, index) => { - if (isContextGroupTool(item.part)) { - if (start < 0) start = index - return - } + const emptyTools: ToolPart[] = [] + + const grouped = createMemo( + () => + groupParts( + props.messages.flatMap((message) => + list(data.store.part?.[message.id], emptyParts) + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ + messageID: message.id, + part, + })), + ), + ), + [] as PartGroup[], + { equals: sameGroups }, + ) - flush(index - 1, false, (item as { part: PartType }).part.type === "tool") - push(`part:${item.message.id}:${id(item.part)}`, { type: "part", part: item.part, message: item.message }) - }) + const last = createMemo(() => grouped().at(-1)?.key) - flush(parts.length - 1, true, false) - return { keys, items } - }) + return ( + <Index each={grouped()}> + {(entryAccessor) => { + const entryType = createMemo(() => entryAccessor().type) + + return ( + <Switch> + <Match when={entryType() === "context"}> + {(() => { + const parts = createMemo( + () => { + const entry = entryAccessor() + if (entry.type !== "context") return emptyTools + return entry.refs + .map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID)) + .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) + }, + emptyTools, + { equals: same }, + ) + const busy = createMemo(() => props.working && last() === entryAccessor().key) - const last = createMemo(() => grouped().keys.at(-1)) + return ( + <Show when={parts().length > 0}> + <ContextToolGroup parts={parts()} busy={busy()} /> + </Show> + ) + })()} + </Match> + <Match when={entryType() === "part"}> + {(() => { + const message = createMemo(() => { + const entry = entryAccessor() + if (entry.type !== "part") return + return props.messages.find((item) => item.id === entry.ref.messageID) + }) + const part = createMemo(() => { + const entry = entryAccessor() + if (entry.type !== "part") return + return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID) + }) - return ( - <div data-component="assistant-parts"> - <For each={grouped().keys}> - {(key) => { - const item = createMemo(() => grouped().items[key]) - const ctx = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "context") return - return value - }) - const part = createMemo(() => { - const value = item() - if (!value) return - if (value.type !== "part") return - return value - }) - const tail = createMemo(() => last() === key) - const tool = createMemo(() => { - const value = part() - if (!value) return false - return value.part.type === "tool" - }) - const context = createMemo(() => !!part()?.context) - const contextSpring = createMemo(() => { - const entry = part() - if (!entry?.context) return undefined - if (!groupState.controlled(entry.groupKey)) return undefined - return COLLAPSIBLE_SPRING - }) - const contextOpen = createMemo(() => { - const value = ctx() - if (value) return groupState.read(value.groupKey, true) - return groupState.read(part()?.groupKey, true) - }) - const visible = createMemo(() => { - if (!context()) return true - if (ctx()) return true - return false - }) - - const turnSummary = createMemo(() => { - const value = part() - if (!value) return false - if (value.part.type !== "text") return false - if (!props.showTurnDiffSummary) return false - return props.showAssistantCopyPartID === value.part.id - }) - const fade = createMemo(() => { - if (ctx()) return true - return tool() - }) - const edge = createMemo(() => { - const entry = part() - if (!entry) return false - if (entry.part.type !== "text") return false - if (!props.working) return false - return tail() - }) - const watch = createMemo(() => !context() && !tool() && tail() && !turnSummary()) - const ctxPartsCache = new Map<string, ToolPart>() - let ctxPartsPrev: ToolPart[] = [] - const ctxParts = createMemo(() => { - const parts = ctx()?.parts ?? [] - if (parts.length === 0 && ctxPartsPrev.length > 0) return ctxPartsPrev - const result: ToolPart[] = [] - for (const item of parts) { - const k = item.part.callID || item.part.id - const cached = ctxPartsCache.get(k) - if (cached) { - result.push(cached) - } else { - ctxPartsCache.set(k, item.part) - result.push(item.part) - } - } - ctxPartsPrev = result - return result - }) - const ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail)) - const shell = createMemo(() => { - const value = part() - if (!value) return - if (value.part.type !== "tool") return - if (value.part.tool !== "bash") return - return value.part - }) - const kind = createMemo(() => { - if (ctx()) return "context" - if (shell()) return "shell" - const value = part() - if (!value) return "part" - return value.part.type - }) - const shown = createMemo(() => { - if (ctx()) return true - if (shell()) return true - const entry = part() - if (!entry) return false - return !entry.context - }) - const partGrowProps = () => ({ - animate: props.animate, - gap: 0, - fade: fade(), - edge: edge(), - edgeHeight: 20, - edgeOpacity: 0.95, - edgeIdle: 100, - edgeFade: 0.6, - edgeRise: 0.1, - grow: true, - watch: watch(), - animateToggle: true, - open: visible(), - toggleSpring: contextSpring(), - }) - return ( - <Show when={shown()}> - <div data-component="assistant-part-item" data-kind={kind()} data-last={tail() ? "true" : "false"}> - <Show when={ctx()}> - {(entry) => ( - <> - <PartGrow {...partGrowProps()}> - <ContextToolGroupHeader - parts={ctxParts()} - pending={ctxPending()} - open={contextOpen()} - onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)} - /> - </PartGrow> - <ContextToolExpandedList parts={ctxParts()} expanded={contextOpen() && !ctxPending()} /> - <ContextToolRollingResults parts={ctxParts()} pending={contextOpen() && ctxPending()} /> - </> - )} - </Show> - <Show when={shell()}> - {(value) => ( - <ShellRollingResults - part={value()} - animate={props.animate} - defaultOpen={props.shellToolDefaultOpen} - /> - )} - </Show> - <Show when={!shell() ? part() : undefined}> - {(entry) => ( - <Show when={!entry().context}> - <PartGrow {...partGrowProps()}> - <div> - <Part - part={entry().part} - message={entry().message} - showAssistantCopyPartID={props.showAssistantCopyPartID} - showTurnDiffSummary={props.showTurnDiffSummary} - turnDiffSummary={props.turnDiffSummary} - defaultOpen={partDefaultOpen( - entry().part, - props.shellToolDefaultOpen, - props.editToolDefaultOpen, - )} - hideDetails={false} - animate={props.animate} - working={props.working} - /> - </div> - </PartGrow> + return ( + <Show when={message()}> + <Show when={part()}> + <Part + part={part()!} + message={message()!} + showAssistantCopyPartID={props.showAssistantCopyPartID} + turnDurationMs={props.turnDurationMs} + defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} + /> </Show> - )} - </Show> - </div> - </Show> - ) - }} - </For> - </div> + </Show> + ) + })()} + </Match> + </Switch> + ) + }} + </Index> ) } @@ -618,6 +560,76 @@ function isContextGroupTool(part: PartType): part is ToolPart { return part.type === "tool" && CONTEXT_GROUP_TOOLS.has(part.tool) } +function contextToolDetail(part: ToolPart): string | undefined { + const info = getToolInfo(part.tool, part.state.input ?? {}) + if (info.subtitle) return info.subtitle + if (part.state.status === "error") return part.state.error + if ((part.state.status === "running" || part.state.status === "completed") && part.state.title) + return part.state.title + const description = part.state.input?.description + if (typeof description === "string") return description + return undefined +} + +function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) { + const input = (part.state.input ?? {}) as Record<string, unknown> + const path = typeof input.path === "string" ? input.path : "/" + const filePath = typeof input.filePath === "string" ? input.filePath : undefined + const pattern = typeof input.pattern === "string" ? input.pattern : undefined + const include = typeof input.include === "string" ? input.include : undefined + const offset = typeof input.offset === "number" ? input.offset : undefined + const limit = typeof input.limit === "number" ? input.limit : undefined + + switch (part.tool) { + case "read": { + const args: string[] = [] + if (offset !== undefined) args.push("offset=" + offset) + if (limit !== undefined) args.push("limit=" + limit) + return { + title: i18n.t("ui.tool.read"), + subtitle: filePath ? getFilename(filePath) : "", + args, + } + } + case "list": + return { + title: i18n.t("ui.tool.list"), + subtitle: getDirectory(path), + } + case "glob": + return { + title: i18n.t("ui.tool.glob"), + subtitle: getDirectory(path), + args: pattern ? ["pattern=" + pattern] : [], + } + case "grep": { + const args: string[] = [] + if (pattern) args.push("pattern=" + pattern) + if (include) args.push("include=" + include) + return { + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(path), + args, + } + } + default: { + const info = getToolInfo(part.tool, input) + return { + title: info.title, + subtitle: info.subtitle || contextToolDetail(part), + args: [], + } + } + } +} + +function contextToolSummary(parts: ToolPart[]) { + const read = parts.filter((part) => part.tool === "read").length + const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length + const list = parts.filter((part) => part.tool === "list").length + return { read, search, list } +} + function ExaOutput(props: { output?: string }) { const links = createMemo(() => urls(props.output)) @@ -648,11 +660,210 @@ export function registerPartComponent(type: string, component: PartComponent) { PART_MAPPING[type] = component } +export function Message(props: MessageProps) { + return ( + <Switch> + <Match when={props.message.role === "user" && props.message}> + {(userMessage) => ( + <UserMessageDisplay + message={userMessage() as UserMessage} + parts={props.parts} + interrupted={props.interrupted} + queued={props.queued} + /> + )} + </Match> + <Match when={props.message.role === "assistant" && props.message}> + {(assistantMessage) => ( + <AssistantMessageDisplay + message={assistantMessage() as AssistantMessage} + parts={props.parts} + showAssistantCopyPartID={props.showAssistantCopyPartID} + showReasoningSummaries={props.showReasoningSummaries} + /> + )} + </Match> + </Switch> + ) +} + +export function AssistantMessageDisplay(props: { + message: AssistantMessage + parts: PartType[] + showAssistantCopyPartID?: string | null + showReasoningSummaries?: boolean +}) { + const emptyTools: ToolPart[] = [] + const grouped = createMemo( + () => + groupParts( + props.parts + .filter((part) => renderable(part, props.showReasoningSummaries ?? true)) + .map((part) => ({ + messageID: props.message.id, + part, + })), + ), + [] as PartGroup[], + { equals: sameGroups }, + ) + + return ( + <Index each={grouped()}> + {(entryAccessor) => { + const entryType = createMemo(() => entryAccessor().type) + + return ( + <Switch> + <Match when={entryType() === "context"}> + {(() => { + const parts = createMemo( + () => { + const entry = entryAccessor() + if (entry.type !== "context") return emptyTools + return entry.refs + .map((ref) => partByID(props.parts, ref.partID)) + .filter((part): part is ToolPart => !!part && isContextGroupTool(part)) + }, + emptyTools, + { equals: same }, + ) + + return ( + <Show when={parts().length > 0}> + <ContextToolGroup parts={parts()} /> + </Show> + ) + })()} + </Match> + <Match when={entryType() === "part"}> + {(() => { + const part = createMemo(() => { + const entry = entryAccessor() + if (entry.type !== "part") return + return partByID(props.parts, entry.ref.partID) + }) + + return ( + <Show when={part()}> + <Part + part={part()!} + message={props.message} + showAssistantCopyPartID={props.showAssistantCopyPartID} + /> + </Show> + ) + })()} + </Match> + </Switch> + ) + }} + </Index> + ) +} + +function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) { + const i18n = useI18n() + const [open, setOpen] = createSignal(false) + const pending = createMemo( + () => + !!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"), + ) + const summary = createMemo(() => contextToolSummary(props.parts)) + + return ( + <Collapsible open={open()} onOpenChange={setOpen} variant="ghost"> + <Collapsible.Trigger> + <div data-component="context-tool-group-trigger"> + <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={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> + <Collapsible.Arrow /> + </div> + </Collapsible.Trigger> + <Collapsible.Content> + <div data-component="context-tool-group-list"> + <Index each={props.parts}> + {(partAccessor) => { + const trigger = createMemo(() => contextToolTrigger(partAccessor(), i18n)) + const running = createMemo( + () => partAccessor().state.status === "pending" || partAccessor().state.status === "running", + ) + return ( + <div data-slot="context-tool-group-item"> + <div data-component="tool-trigger"> + <div data-slot="basic-tool-tool-trigger-content"> + <div data-slot="basic-tool-tool-info"> + <div data-slot="basic-tool-tool-info-structured"> + <div data-slot="basic-tool-tool-info-main"> + <span data-slot="basic-tool-tool-title"> + <TextShimmer text={trigger().title} active={running()} /> + </span> + <Show when={!running() && trigger().subtitle}> + <span data-slot="basic-tool-tool-subtitle">{trigger().subtitle}</span> + </Show> + <Show when={!running() && trigger().args?.length}> + <For each={trigger().args}> + {(arg) => <span data-slot="basic-tool-tool-arg">{arg}</span>} + </For> + </Show> + </div> + </div> + </div> + </div> + </div> + </div> + ) + }} + </Index> + </div> + </Collapsible.Content> + </Collapsible> + ) +} + export function UserMessageDisplay(props: { message: UserMessage parts: PartType[] interrupted?: boolean - animate?: boolean queued?: boolean }) { const data = useData() @@ -702,9 +913,14 @@ export function UserMessageDisplay(props: { return `${hour12}:${minute} ${hours < 12 ? "AM" : "PM"}` }) - const userMeta = createMemo(() => { + const metaHead = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model(), stamp()] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] + return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") + }) + + const metaTail = createMemo(() => { + const items = [stamp(), props.interrupted ? i18n.t("ui.message.interrupted") : ""] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -721,83 +937,93 @@ export function UserMessageDisplay(props: { } return ( - <GrowBox animate={!!props.animate} fade class="w-full min-w-0 self-stretch max-w-full"> - <div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}> - <div data-slot="user-message-inner"> - <Show when={attachments().length > 0}> - <div data-slot="user-message-attachments"> - <For each={attachments()}> - {(file) => ( - <div - data-slot="user-message-attachment" - data-type={file.mime.startsWith("image/") ? "image" : "file"} - data-queued={props.queued ? "" : undefined} - onClick={() => { - if (file.mime.startsWith("image/") && file.url) { - openImagePreview(file.url, file.filename) - } - }} - > - <Show - when={file.mime.startsWith("image/") && file.url} - fallback={ - <div data-slot="user-message-attachment-icon"> - <Icon name="folder" /> - </div> - } - > - <img - data-slot="user-message-attachment-image" - src={file.url} - alt={file.filename ?? i18n.t("ui.message.attachment.alt")} - /> - </Show> - </div> - )} - </For> + <div data-component="user-message" data-interrupted={props.interrupted ? "" : undefined}> + <Show when={attachments().length > 0}> + <div data-slot="user-message-attachments"> + <For each={attachments()}> + {(file) => ( + <div + data-slot="user-message-attachment" + data-type={file.mime.startsWith("image/") ? "image" : "file"} + data-queued={props.queued ? "" : undefined} + onClick={() => { + if (file.mime.startsWith("image/") && file.url) { + openImagePreview(file.url, file.filename) + } + }} + > + <Show + when={file.mime.startsWith("image/") && file.url} + fallback={ + <div data-slot="user-message-attachment-icon"> + <Icon name="folder" /> + </div> + } + > + <img + data-slot="user-message-attachment-image" + src={file.url} + alt={file.filename ?? i18n.t("ui.message.attachment.alt")} + /> + </Show> + </div> + )} + </For> + </div> + </Show> + <Show when={text()}> + <> + <div data-slot="user-message-body"> + <div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}> + <HighlightedText text={text()} references={inlineFiles()} agents={agents()} /> </div> - </Show> - <Show when={text()}> - <> - <div data-slot="user-message-body"> - <div data-slot="user-message-text" data-queued={props.queued ? "" : undefined}> - <HighlightedText text={text()} references={inlineFiles()} agents={agents()} /> - </div> - <GrowBox animate={!!props.animate} open={!!props.queued}> - <div data-slot="user-message-queued-indicator"> - <TextShimmer text={i18n.t("ui.message.queued")} /> - </div> - </GrowBox> + <Show when={props.queued}> + <div data-slot="user-message-queued-indicator"> + <TextShimmer text={i18n.t("ui.message.queued")} /> </div> - <div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}> - <Show when={userMeta()}> + </Show> + </div> + <div data-slot="user-message-copy-wrapper" data-interrupted={props.interrupted ? "" : undefined}> + <Show when={metaHead() || metaTail()}> + <span data-slot="user-message-meta-wrap"> + <Show when={metaHead()}> <span data-slot="user-message-meta" class="text-12-regular text-text-weak cursor-default"> - {userMeta()} + {metaHead()} </span> </Show> - <Tooltip - value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - placement="top" - gutter={4} - > - <IconButton - icon={copied() ? "check" : "copy"} - size="normal" - variant="ghost" - onMouseDown={(e) => e.preventDefault()} - onClick={(event) => { - event.stopPropagation() - handleCopy() - }} - aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} - /> - </Tooltip> - </div> - </> - </Show> - </div> - </div> - </GrowBox> + <Show when={metaHead() && metaTail()}> + <span data-slot="user-message-meta-sep" class="text-12-regular text-text-weak cursor-default"> + {"\u00A0\u00B7\u00A0"} + </span> + </Show> + <Show when={metaTail()}> + <span data-slot="user-message-meta-tail" class="text-12-regular text-text-weak cursor-default"> + {metaTail()} + </span> + </Show> + </span> + </Show> + <Tooltip + value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + placement="top" + gutter={4} + > + <IconButton + icon={copied() ? "check" : "copy"} + size="normal" + variant="ghost" + onMouseDown={(e) => e.preventDefault()} + onClick={(event) => { + event.stopPropagation() + handleCopy() + }} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyMessage")} + /> + </Tooltip> + </div> + </> + </Show> + </div> ) } @@ -851,10 +1077,7 @@ export function Part(props: MessagePartProps) { hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} showAssistantCopyPartID={props.showAssistantCopyPartID} - showTurnDiffSummary={props.showTurnDiffSummary} - turnDiffSummary={props.turnDiffSummary} - animate={props.animate} - working={props.working} + turnDurationMs={props.turnDurationMs} /> </Show> ) @@ -864,16 +1087,12 @@ export interface ToolProps { input: Record<string, any> metadata: Record<string, any> tool: string - partID?: string - callID?: string output?: string status?: string hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean locked?: boolean - animate?: boolean - reveal?: boolean } export type ToolComponent = Component<ToolProps> @@ -907,7 +1126,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre <Accordion multiple data-scope="apply-patch" - style={{ "--sticky-accordion-offset": "37px" }} + style={{ "--sticky-accordion-offset": "40px" }} defaultValue={[value()]} > <Accordion.Item value={value()}> @@ -938,26 +1157,30 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre PART_MAPPING["tool"] = function ToolPartDisplay(props) { const i18n = useI18n() - const part = props.part as ToolPart - const hideQuestion = createMemo(() => part.tool === "question" && busy(part.state.status)) + const part = () => props.part as ToolPart + if (part().tool === "todowrite" || part().tool === "todoread") return null + + const hideQuestion = createMemo( + () => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"), + ) const emptyInput: Record<string, any> = {} const emptyMetadata: Record<string, any> = {} - const input = () => part.state?.input ?? emptyInput + const input = () => part().state?.input ?? emptyInput // @ts-expect-error - const partMetadata = () => part.state?.metadata ?? emptyMetadata + const partMetadata = () => part().state?.metadata ?? emptyMetadata - const render = createMemo(() => ToolRegistry.render(part.tool) ?? GenericTool) + const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool) return ( <Show when={!hideQuestion()}> - <div data-component="tool-part-wrapper" data-tool={part.tool}> + <div data-component="tool-part-wrapper"> <Switch> - <Match when={part.state.status === "error" && part.state.error}> + <Match when={part().state.status === "error" && (part().state as any).error}> {(error) => { const cleaned = error().replace("Error: ", "") - if (part.tool === "question" && cleaned.includes("dismissed this question")) { + if (part().tool === "question" && cleaned.includes("dismissed this question")) { return ( <div style="width: 100%; display: flex; justify-content: flex-end;"> <span class="text-13-regular text-text-weak cursor-default"> @@ -991,17 +1214,13 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { <Dynamic component={render()} input={input()} - tool={part.tool} - partID={part.id} - callID={part.callID} + tool={part().tool} metadata={partMetadata()} // @ts-expect-error - output={part.state.output} - status={part.state.status} + output={part().state.output} + status={part().state.status} hideDetails={props.hideDetails} defaultOpen={props.defaultOpen} - animate - reveal={props.animate} /> </Match> </Switch> @@ -1026,16 +1245,74 @@ PART_MAPPING["compaction"] = function CompactionPartDisplay() { } PART_MAPPING["text"] = function TextPartDisplay(props) { + const data = useData() + const i18n = useI18n() const part = () => props.part as TextPart + const interrupted = createMemo( + () => + props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", + ) + + const model = createMemo(() => { + if (props.message.role !== "assistant") return "" + const message = props.message as AssistantMessage + const match = data.store.provider?.all?.find((p) => p.id === message.providerID) + return match?.models?.[message.modelID]?.name ?? message.modelID + }) + + const duration = createMemo(() => { + if (props.message.role !== "assistant") return "" + const message = props.message as AssistantMessage + const completed = message.time.completed + const ms = + typeof props.turnDurationMs === "number" + ? props.turnDurationMs + : typeof completed === "number" + ? completed - message.time.created + : -1 + if (!(ms >= 0)) return "" + const total = Math.round(ms / 1000) + if (total < 60) return `${total}s` + const minutes = Math.floor(total / 60) + const seconds = total % 60 + return `${minutes}m ${seconds}s` + }) + + const meta = createMemo(() => { + if (props.message.role !== "assistant") return "" + const agent = (props.message as AssistantMessage).agent + const items = [ + agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", + model(), + duration(), + interrupted() ? i18n.t("ui.message.interrupted") : "", + ] + return items.filter((x) => !!x).join(" \u00B7 ") + }) const displayText = () => (part().text ?? "").trim() const throttledText = createThrottledValue(displayText) - const summary = createMemo(() => { - if (props.message.role !== "assistant") return - if (!props.showTurnDiffSummary) return - if (props.showAssistantCopyPartID !== part().id) return - return props.turnDiffSummary + const isLastTextPart = createMemo(() => { + const last = (data.store.part?.[props.message.id] ?? []) + .filter((item): item is TextPart => item?.type === "text" && !!item.text?.trim()) + .at(-1) + return last?.id === part().id }) + const showCopy = createMemo(() => { + if (props.message.role !== "assistant") return isLastTextPart() + if (props.showAssistantCopyPartID === null) return false + if (typeof props.showAssistantCopyPartID === "string") return props.showAssistantCopyPartID === part().id + return isLastTextPart() + }) + const [copied, setCopied] = createSignal(false) + + const handleCopy = async () => { + const content = displayText() + if (!content) return + await navigator.clipboard.writeText(content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } return ( <Show when={throttledText()}> @@ -1043,12 +1320,28 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { <div data-slot="text-part-body"> <Markdown text={throttledText()} cacheKey={part().id} /> </div> - <Show when={summary()}> - {(render) => ( - <GrowBox animate={!!props.animate} fade gap={4} class="w-full min-w-0"> - <div data-slot="text-part-turn-summary">{render()()}</div> - </GrowBox> - )} + <Show when={showCopy()}> + <div data-slot="text-part-copy-wrapper" data-interrupted={interrupted() ? "" : undefined}> + <Tooltip + value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + placement="top" + gutter={4} + > + <IconButton + icon={copied() ? "check" : "copy"} + size="normal" + variant="ghost" + onMouseDown={(e) => e.preventDefault()} + onClick={handleCopy} + aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")} + /> + </Tooltip> + <Show when={meta()}> + <span data-slot="text-part-meta" class="text-12-regular text-text-weak cursor-default"> + {meta()} + </span> + </Show> + </div> </Show> </div> </Show> @@ -1078,33 +1371,30 @@ ToolRegistry.register({ if (props.input.offset) args.push("offset=" + props.input.offset) if (props.input.limit) args.push("limit=" + props.input.limit) const loaded = createMemo(() => { + if (props.status !== "completed") return [] const value = props.metadata.loaded if (!value || !Array.isArray(value)) return [] return value.filter((p): p is string => typeof p === "string") }) - const pending = createMemo(() => busy(props.status)) return ( <> - <ToolCall - variant="row" + <BasicTool {...props} icon="glasses" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.read")} - pending={pending()} - subtitle={props.input.filePath ? getFilename(props.input.filePath) : ""} - args={args} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.read"), + subtitle: props.input.filePath ? getFilename(props.input.filePath) : "", + args, + }} /> <For each={loaded()}> {(filepath) => ( - <ToolLoadedFile - text={`${i18n.t("ui.tool.loaded")} ${relativizeProjectPath(filepath, data.directory)}`} - animate={props.reveal} - /> + <div data-component="tool-loaded-file"> + <Icon name="enter" size="small" /> + <span> + {i18n.t("ui.tool.loaded")} {relativizeProjectPath(filepath, data.directory)} + </span> + </div> )} </For> </> @@ -1116,29 +1406,18 @@ ToolRegistry.register({ name: "list", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="bullet-list" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.list")} - pending={pending()} - subtitle={getDirectory(props.input.path)} - animate={props.reveal} - /> - } + trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -1147,30 +1426,22 @@ ToolRegistry.register({ name: "glob", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="magnifying-glass-menu" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.glob")} - pending={pending()} - subtitle={getDirectory(props.input.path)} - args={props.input.pattern ? ["pattern=" + props.input.pattern] : []} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.glob"), + subtitle: getDirectory(props.input.path || "/"), + args: props.input.pattern ? ["pattern=" + props.input.pattern] : [], + }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -1182,214 +1453,40 @@ ToolRegistry.register({ const args: string[] = [] if (props.input.pattern) args.push("pattern=" + props.input.pattern) if (props.input.include) args.push("include=" + props.input.include) - const pending = createMemo(() => busy(props.status)) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="magnifying-glass-menu" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.grep")} - pending={pending()} - subtitle={getDirectory(props.input.path)} - args={args} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.grep"), + subtitle: getDirectory(props.input.path || "/"), + args, + }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> - </ToolCall> + </BasicTool> ) }, }) -function useToolReveal(pending: () => boolean, animate?: () => boolean) { - const enabled = () => animate?.() ?? true - const [live, setLive] = createSignal(pending() || enabled()) - createEffect(() => { - if (pending()) setLive(true) - }) - return () => enabled() && live() -} - -function WebfetchMeta(props: { url: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <span ref={ref} data-slot="webfetch-meta"> - <a - data-slot="basic-tool-tool-subtitle" - class="clickable subagent-link" - href={props.url} - target="_blank" - rel="noopener noreferrer" - onClick={(event) => event.stopPropagation()} - > - {props.url} - </a> - <div data-component="tool-action"> - <Icon name="square-arrow-top-right" size="small" /> - </div> - </span> - ) -} - -function TaskLink(props: { href: string; text: string; onClick: (e: MouseEvent) => void; animate?: boolean }) { - let ref: HTMLAnchorElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <a - ref={ref} - data-slot="basic-tool-tool-subtitle" - class="clickable subagent-link" - href={props.href} - onClick={props.onClick} - > - {props.text} - </a> - ) -} - -function ToolText(props: { text: string; delay?: number; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay, wipe: true, animate: props.animate }) - - return ( - <span ref={ref} data-slot="basic-tool-tool-subtitle"> - {props.text} - </span> - ) -} - -function ToolLoadedFile(props: { text: string; animate?: boolean }) { - let ref: HTMLDivElement | undefined - useToolFade(() => ref, { delay: 0.02, wipe: true, animate: props.animate }) - - return ( - <GrowBox animate={props.animate !== false} fade={false} class="w-full min-w-0"> - <div ref={ref} data-component="tool-loaded-file"> - <Icon name="enter" size="small" /> - <span>{props.text}</span> - </div> - </GrowBox> - ) -} - -function ToolTriggerRow(props: { - title: string - pending: boolean - subtitle?: string - args?: string[] - action?: JSX.Element - animate?: boolean - revealOnMount?: boolean -}) { - const reveal = useToolReveal( - () => props.pending, - () => props.animate !== false, - ) - const detail = createMemo(() => [props.subtitle, ...(props.args ?? [])].filter((x): x is string => !!x).join(" ")) - const detailAnimate = createMemo(() => { - if (props.animate === false) return false - if (props.revealOnMount) return true - if (!props.pending && !reveal()) return true - return reveal() - }) - - return ( - <div data-slot="basic-tool-tool-info-structured"> - <div data-slot="basic-tool-tool-info-main"> - <span data-slot="basic-tool-tool-title"> - <TextShimmer text={props.title} active={props.pending} /> - </span> - <Show when={detail()}>{(text) => <ToolText text={text()} animate={detailAnimate()} />}</Show> - </div> - <Show when={props.action}>{props.action}</Show> - </div> - ) -} - -type DiffValue = { additions: number; deletions: number } | { additions: number; deletions: number }[] - -function ToolMetaLine(props: { - filename: string - path?: string - changes?: DiffValue - delay?: number - animate?: boolean - soft?: boolean -}) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { delay: props.delay ?? 0.02, wipe: true, animate: props.animate }) - - return ( - <span - ref={ref} - data-slot={props.soft ? "basic-tool-tool-subtitle" : "message-part-meta-line"} - classList={{ - "message-part-meta-line": !!props.soft, - soft: !!props.soft, - }} - > - <span data-slot="message-part-title-filename">{props.filename}</span> - <Show when={props.path}> - <span data-slot="message-part-directory-inline">{props.path}</span> - </Show> - <Show when={props.changes}>{(changes) => <DiffChanges changes={changes()} />}</Show> - </span> - ) -} - -function ToolChanges(props: { changes: DiffValue; animate?: boolean }) { - let ref: HTMLDivElement | undefined - useToolFade(() => ref, { delay: 0.04, animate: props.animate }) - - return ( - <div ref={ref}> - <DiffChanges changes={props.changes} /> - </div> - ) -} - -function ShellText(props: { text: string; animate?: boolean }) { - let ref: HTMLSpanElement | undefined - useToolFade(() => ref, { wipe: true, animate: props.animate }) - - return ( - <span data-component="shell-submessage"> - <span data-slot="basic-tool-tool-subtitle"> - <span ref={ref} data-slot="shell-submessage-value"> - {props.text} - </span> - </span> - </span> - ) -} - ToolRegistry.register({ name: "webfetch", render(props) { const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const url = createMemo(() => { const value = props.input.url if (typeof value !== "string") return "" return value }) return ( - <ToolCall - variant="row" + <BasicTool {...props} + hideDetails icon="window-cursor" trigger={ <div data-slot="basic-tool-tool-info-structured"> @@ -1397,8 +1494,24 @@ ToolRegistry.register({ <span data-slot="basic-tool-tool-title"> <TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} /> </span> - <Show when={url()}>{(value) => <WebfetchMeta url={value()} animate={reveal()} />}</Show> + <Show when={!pending() && url()}> + <a + data-slot="basic-tool-tool-subtitle" + class="clickable subagent-link" + href={url()} + target="_blank" + rel="noopener noreferrer" + onClick={(event) => event.stopPropagation()} + > + {url()} + </a> + </Show> </div> + <Show when={!pending() && url()}> + <div data-component="tool-action"> + <Icon name="square-arrow-top-right" size="small" /> + </div> + </Show> </div> } /> @@ -1417,8 +1530,7 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="window-cursor" trigger={{ @@ -1428,7 +1540,7 @@ ToolRegistry.register({ }} > <ExaOutput output={props.output} /> - </ToolCall> + </BasicTool> ) }, }) @@ -1444,8 +1556,7 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="code" trigger={{ @@ -1455,7 +1566,7 @@ ToolRegistry.register({ }} > <ExaOutput output={props.output} /> - </ToolCall> + </BasicTool> ) }, }) @@ -1465,6 +1576,7 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined const type = createMemo(() => { const raw = props.input.subagent_type @@ -1477,8 +1589,7 @@ ToolRegistry.register({ if (typeof value === "string") return value return undefined }) - const running = createMemo(() => busy(props.status)) - const reveal = useToolReveal(running, () => props.reveal !== false) + const running = createMemo(() => props.status === "pending" || props.status === "running") const href = createMemo(() => { const sessionId = childSessionId() @@ -1487,49 +1598,34 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - if (typeof window === "undefined") return - const path = window.location.pathname + const path = location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}` }) - const handleLinkClick = (e: MouseEvent) => { - const sessionId = childSessionId() - const url = href() - if (!sessionId || !url) return - - e.stopPropagation() - - if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return - - const nav = data.navigateToSession - if (!nav || typeof window === "undefined") return - - e.preventDefault() - const before = window.location.pathname + window.location.search + window.location.hash - nav(sessionId) - setTimeout(() => { - const after = window.location.pathname + window.location.search + window.location.hash - if (after === before) window.location.assign(url) - }, 50) - } + const titleContent = () => <TextShimmer text={title()} active={running()} /> const trigger = () => ( <div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-main"> - <span data-slot="basic-tool-tool-title"> - <TextShimmer text={title()} active={running()} /> + <span data-slot="basic-tool-tool-title" class="capitalize agent-title"> + {titleContent()} </span> <Show when={description()}> <Switch> <Match when={href()}> - {(url) => ( - <TaskLink href={url()} text={description() ?? ""} onClick={handleLinkClick} animate={reveal()} /> - )} + <a + data-slot="basic-tool-tool-subtitle" + class="clickable subagent-link" + href={href()!} + onClick={(e) => e.stopPropagation()} + > + {description()} + </a> </Match> <Match when={true}> - <ToolText text={description() ?? ""} delay={0.02} animate={reveal()} /> + <span data-slot="basic-tool-tool-subtitle">{description()}</span> </Match> </Switch> </Show> @@ -1537,7 +1633,7 @@ ToolRegistry.register({ </div> ) - return <ToolCall variant="row" icon="task" status={props.status} trigger={trigger()} animate /> + return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails /> }, }) @@ -1545,26 +1641,13 @@ ToolRegistry.register({ name: "bash", render(props) { const i18n = useI18n() - const pending = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) - const subtitle = () => props.input.description ?? props.metadata.description - const cmd = createMemo(() => { - const value = props.input.command ?? props.metadata.command - if (typeof value === "string") return value - return "" - }) - const output = createMemo(() => { - if (typeof props.output === "string") return props.output - if (typeof props.metadata.output === "string") return props.metadata.output - return "" - }) - const command = createMemo(() => `$ ${cmd()}`) - const result = createMemo(() => stripAnsi(output())) + const pending = () => props.status === "pending" || props.status === "running" + const sawPending = pending() const text = createMemo(() => { - const value = result() - return `${command()}${value ? "\n\n" + value : ""}` + const cmd = props.input.command ?? props.metadata.command ?? "" + const out = stripAnsi(props.output || props.metadata.output || "") + return `$ ${cmd}${out ? "\n\n" + out : ""}` }) - const hasOutput = createMemo(() => result().length > 0) const [copied, setCopied] = createSignal(false) const handleCopy = async () => { @@ -1576,20 +1659,18 @@ ToolRegistry.register({ } return ( - <ToolCall - variant="panel" + <BasicTool {...props} icon="console" - animate - springContent - defaultOpen={false} trigger={ <div data-slot="basic-tool-tool-info-structured"> <div data-slot="basic-tool-tool-info-main"> <span data-slot="basic-tool-tool-title"> <TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} /> </span> - <Show when={subtitle()}>{(text) => <ShellText text={text()} animate={reveal()} />}</Show> + <Show when={!pending() && props.input.description}> + <ShellSubmessage text={props.input.description} animate={sawPending} /> + </Show> </div> </div> } @@ -1617,7 +1698,7 @@ ToolRegistry.register({ </pre> </div> </div> - </ToolCall> + </BasicTool> ) }, }) @@ -1630,12 +1711,10 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.metadata?.filediff?.file || props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = () => props.status === "pending" || props.status === "running" return ( <div data-component="edit-tool"> - <ToolCall - variant="panel" + <BasicTool {...props} icon="code-lines" defer @@ -1646,17 +1725,20 @@ ToolRegistry.register({ <span data-slot="message-part-title-text"> <TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} /> </span> - <Show when={filename()}> - {(name) => ( - <ToolMetaLine - filename={name()} - path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} - changes={props.metadata.filediff} - animate={reveal()} - /> - )} + <Show when={!pending()}> + <span data-slot="message-part-title-filename">{filename()}</span> </Show> </div> + <Show when={!pending() && props.input.filePath?.includes("/")}> + <div data-slot="message-part-path"> + <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> + </div> + </Show> + </div> + <div data-slot="message-part-actions"> + <Show when={!pending() && props.metadata.filediff}> + <DiffChanges changes={props.metadata.filediff} /> + </Show> </div> </div> } @@ -1666,7 +1748,7 @@ ToolRegistry.register({ path={path()} actions={ <Show when={!pending() && props.metadata.filediff}> - {(diff) => <ToolChanges changes={diff()} animate={reveal()} />} + <DiffChanges changes={props.metadata.filediff!} /> </Show> } > @@ -1687,7 +1769,7 @@ ToolRegistry.register({ </ToolFileAccordion> </Show> <DiagnosticsDisplay diagnostics={diagnostics()} /> - </ToolCall> + </BasicTool> </div> ) }, @@ -1701,12 +1783,10 @@ ToolRegistry.register({ const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath)) const path = createMemo(() => props.input.filePath || "") const filename = () => getFilename(props.input.filePath ?? "") - const pending = () => busy(props.status) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = () => props.status === "pending" || props.status === "running" return ( <div data-component="write-tool"> - <ToolCall - variant="panel" + <BasicTool {...props} icon="code-lines" defer @@ -1717,17 +1797,17 @@ ToolRegistry.register({ <span data-slot="message-part-title-text"> <TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} /> </span> - <Show when={filename()}> - {(name) => ( - <ToolMetaLine - filename={name()} - path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} - animate={reveal()} - /> - )} + <Show when={!pending()}> + <span data-slot="message-part-title-filename">{filename()}</span> </Show> </div> + <Show when={!pending() && props.input.filePath?.includes("/")}> + <div data-slot="message-part-path"> + <span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span> + </div> + </Show> </div> + <div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div> </div> } > @@ -1748,7 +1828,7 @@ ToolRegistry.register({ </ToolFileAccordion> </Show> <DiagnosticsDisplay diagnostics={diagnostics()} /> - </ToolCall> + </BasicTool> </div> ) }, @@ -1772,8 +1852,7 @@ ToolRegistry.register({ const i18n = useI18n() const fileComponent = useFileComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) - const pending = createMemo(() => busy(props.status)) - const reveal = useToolReveal(pending, () => props.reveal !== false) + const pending = createMemo(() => props.status === "pending" || props.status === "running") const single = createMemo(() => { const list = files() if (list.length !== 1) return @@ -1781,6 +1860,7 @@ ToolRegistry.register({ }) const [expanded, setExpanded] = createSignal<string[]>([]) let seeded = false + createEffect(() => { const list = files() if (list.length === 0) return @@ -1788,6 +1868,7 @@ ToolRegistry.register({ seeded = true setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath)) }) + const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" @@ -1795,44 +1876,24 @@ ToolRegistry.register({ }) return ( - <div data-component="apply-patch-tool"> - <ToolCall - variant="panel" - {...props} - icon="code-lines" - defer - trigger={ - <div data-component={single() ? "edit-trigger" : "write-trigger"}> - <div data-slot="message-part-title-area"> - <div data-slot="message-part-title"> - <span data-slot="message-part-title-text"> - <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> - </span> - <Show when={single()}> - {(file) => ( - <ToolMetaLine - filename={getFilename(file().relativePath)} - path={file().relativePath.includes("/") ? getDirectory(file().relativePath) : undefined} - changes={{ additions: file().additions, deletions: file().deletions }} - animate={reveal()} - soft - /> - )} - </Show> - <Show when={!single() && subtitle()}>{(text) => <ToolText text={text()} animate={reveal()} />}</Show> - </div> - </div> - </div> - } - > - <Show - when={single()} - fallback={ + <Show + when={single()} + fallback={ + <div data-component="apply-patch-tool"> + <BasicTool + {...props} + icon="code-lines" + defer + trigger={{ + title: i18n.t("ui.tool.patch"), + subtitle: subtitle(), + }} + > <Show when={files().length > 0}> <Accordion multiple data-scope="apply-patch" - style={{ "--sticky-accordion-offset": "37px" }} + style={{ "--sticky-accordion-offset": "40px" }} value={expanded()} onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} > @@ -1840,11 +1901,13 @@ ToolRegistry.register({ {(file) => { const active = createMemo(() => expanded().includes(file.filePath)) const [visible, setVisible] = createSignal(false) + createEffect(() => { if (!active()) { setVisible(false) return } + requestAnimationFrame(() => { if (!active()) return setVisible(true) @@ -1909,50 +1972,77 @@ ToolRegistry.register({ </For> </Accordion> </Show> + </BasicTool> + </div> + } + > + <div data-component="apply-patch-tool"> + <BasicTool + {...props} + icon="code-lines" + defer + trigger={ + <div data-component="edit-trigger"> + <div data-slot="message-part-title-area"> + <div data-slot="message-part-title"> + <span data-slot="message-part-title-text"> + <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> + </span> + <Show when={!pending()}> + <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span> + </Show> + </div> + <Show when={!pending() && single()!.relativePath.includes("/")}> + <div data-slot="message-part-path"> + <span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span> + </div> + </Show> + </div> + <div data-slot="message-part-actions"> + <Show when={!pending()}> + <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> + </Show> + </div> + </div> } > - {(file) => ( - <ToolFileAccordion - path={file().relativePath} - actions={ - <Switch> - <Match when={file().type === "add"}> - <span data-slot="apply-patch-change" data-type="added"> - {i18n.t("ui.patch.action.created")} - </span> - </Match> - <Match when={file().type === "delete"}> - <span data-slot="apply-patch-change" data-type="removed"> - {i18n.t("ui.patch.action.deleted")} - </span> - </Match> - <Match when={file().type === "move"}> - <span data-slot="apply-patch-change" data-type="modified"> - {i18n.t("ui.patch.action.moved")} - </span> - </Match> - <Match when={true}> - <ToolChanges - changes={{ additions: file().additions, deletions: file().deletions }} - animate={reveal()} - /> - </Match> - </Switch> - } - > - <div data-component="apply-patch-file-diff"> - <Dynamic - component={fileComponent} - mode="diff" - before={{ name: file().filePath, contents: file().before }} - after={{ name: file().movePath ?? file().filePath, contents: file().after }} - /> - </div> - </ToolFileAccordion> - )} - </Show> - </ToolCall> - </div> + <ToolFileAccordion + path={single()!.relativePath} + actions={ + <Switch> + <Match when={single()!.type === "add"}> + <span data-slot="apply-patch-change" data-type="added"> + {i18n.t("ui.patch.action.created")} + </span> + </Match> + <Match when={single()!.type === "delete"}> + <span data-slot="apply-patch-change" data-type="removed"> + {i18n.t("ui.patch.action.deleted")} + </span> + </Match> + <Match when={single()!.type === "move"}> + <span data-slot="apply-patch-change" data-type="modified"> + {i18n.t("ui.patch.action.moved")} + </span> + </Match> + <Match when={true}> + <DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} /> + </Match> + </Switch> + } + > + <div data-component="apply-patch-file-diff"> + <Dynamic + component={fileComponent} + mode="diff" + before={{ name: single()!.filePath, contents: single()!.before }} + after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }} + /> + </div> + </ToolFileAccordion> + </BasicTool> + </div> + </Show> ) }, }) @@ -1970,7 +2060,6 @@ ToolRegistry.register({ return [] }) - const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const list = todos() @@ -1979,19 +2068,14 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} defaultOpen icon="checklist" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.todos")} - pending={pending()} - subtitle={subtitle()} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.todos"), + subtitle: subtitle(), + }} > <Show when={todos().length}> <div data-component="todos"> @@ -2009,7 +2093,7 @@ ToolRegistry.register({ </For> </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -2021,7 +2105,6 @@ ToolRegistry.register({ const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) const completed = createMemo(() => answers().length > 0) - const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { const count = questions().length @@ -2031,19 +2114,14 @@ ToolRegistry.register({ }) return ( - <ToolCall - variant="panel" + <BasicTool {...props} - defaultOpen={false} + defaultOpen={completed()} icon="bubble-5" - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.questions")} - pending={pending()} - subtitle={subtitle()} - animate={props.reveal} - /> - } + trigger={{ + title: i18n.t("ui.tool.questions"), + subtitle: subtitle(), + }} > <Show when={completed()}> <div data-component="question-answers"> @@ -2060,7 +2138,7 @@ ToolRegistry.register({ </For> </div> </Show> - </ToolCall> + </BasicTool> ) }, }) @@ -2068,28 +2146,21 @@ ToolRegistry.register({ ToolRegistry.register({ name: "skill", render(props) { - const i18n = useI18n() - const pending = createMemo(() => busy(props.status)) - const name = createMemo(() => { - const value = props.input.name || props.metadata.name - if (typeof value === "string") return value - }) - return ( - <ToolCall - variant="row" - icon="brain" - status={props.status} - trigger={ - <ToolTriggerRow - title={i18n.t("ui.tool.skill")} - pending={pending()} - subtitle={name()} - animate={props.reveal} - revealOnMount - /> - } - animate - /> + const title = createMemo(() => props.input.name || "skill") + const running = createMemo(() => props.status === "pending" || props.status === "running") + + const titleContent = () => <TextShimmer text={title()} active={running()} /> + + const trigger = () => ( + <div data-slot="basic-tool-tool-info-structured"> + <div data-slot="basic-tool-tool-info-main"> + <span data-slot="basic-tool-tool-title" class="capitalize agent-title"> + {titleContent()} + </span> + </div> + </div> ) + + return <BasicTool icon="brain" status={props.status} trigger={trigger()} hideDetails /> }, }) diff --git a/packages/ui/src/components/motion-spring.tsx b/packages/ui/src/components/motion-spring.tsx index c7ff1fbcd26..a5104a1a3ef 100644 --- a/packages/ui/src/components/motion-spring.tsx +++ b/packages/ui/src/components/motion-spring.tsx @@ -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()) }) diff --git a/packages/ui/src/components/motion.tsx b/packages/ui/src/components/motion.tsx deleted file mode 100644 index 6cdf01c7314..00000000000 --- a/packages/ui/src/components/motion.tsx +++ /dev/null @@ -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 = "" -} diff --git a/packages/ui/src/components/rolling-results.css b/packages/ui/src/components/rolling-results.css deleted file mode 100644 index 200b2a97e93..00000000000 --- a/packages/ui/src/components/rolling-results.css +++ /dev/null @@ -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); - } -} diff --git a/packages/ui/src/components/rolling-results.tsx b/packages/ui/src/components/rolling-results.tsx deleted file mode 100644 index 77ffdb1b349..00000000000 --- a/packages/ui/src/components/rolling-results.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css index a8574cc9f7b..f6a49e241c6 100644 --- a/packages/ui/src/components/scroll-view.css +++ b/packages/ui/src/components/scroll-view.css @@ -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; } diff --git a/packages/ui/src/components/scroll-view.test.ts b/packages/ui/src/components/scroll-view.test.ts new file mode 100644 index 00000000000..d28b51fea8b --- /dev/null +++ b/packages/ui/src/components/scroll-view.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { scrollKey } from "./scroll-view" + +describe("scrollKey", () => { + test("maps plain navigation keys", () => { + expect(scrollKey({ key: "PageDown", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe( + "page-down", + ) + expect(scrollKey({ key: "ArrowUp", altKey: false, ctrlKey: false, metaKey: false, shiftKey: false })).toBe("up") + }) + + test("ignores modified keybinds", () => { + expect( + scrollKey({ key: "ArrowDown", altKey: false, ctrlKey: false, metaKey: true, shiftKey: false }), + ).toBeUndefined() + expect(scrollKey({ key: "PageUp", altKey: false, ctrlKey: true, metaKey: false, shiftKey: false })).toBeUndefined() + expect(scrollKey({ key: "End", altKey: false, ctrlKey: false, metaKey: false, shiftKey: true })).toBeUndefined() + }) +}) diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx index a8d3cf0f841..c3d878af63e 100644 --- a/packages/ui/src/components/scroll-view.tsx +++ b/packages/ui/src/components/scroll-view.tsx @@ -1,18 +1,36 @@ -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 const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + + switch (event.key) { + case "PageDown": + return "page-down" + case "PageUp": + return "page-up" + case "Home": + return "home" + case "End": + return "end" + case "ArrowUp": + return "up" + case "ArrowDown": + return "down" + } } 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 +44,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 +55,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 +76,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 +101,6 @@ export function ScrollView(props: ScrollViewProps) { } onCleanup(() => { - stop() observer.disconnect() }) @@ -131,31 +142,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, @@ -166,31 +152,34 @@ export function ScrollView(props: ScrollViewProps) { return } + const next = scrollKey(e) + if (!next) return + const scrollAmount = viewportRef.clientHeight * 0.8 const lineAmount = 40 - switch (e.key) { - case "PageDown": + switch (next) { + case "page-down": e.preventDefault() viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" }) break - case "PageUp": + case "page-up": e.preventDefault() viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" }) break - case "Home": + case "home": e.preventDefault() - glide(reverse() ? -(viewportRef.scrollHeight - viewportRef.clientHeight) : 0) + viewportRef.scrollTo({ top: 0, behavior: "smooth" }) break - case "End": + case "end": e.preventDefault() - glide(reverse() ? 0 : viewportRef.scrollHeight - viewportRef.clientHeight) + viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) break - case "ArrowUp": + case "up": e.preventDefault() viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" }) break - case "ArrowDown": + case "down": e.preventDefault() viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" }) break @@ -199,6 +188,7 @@ export function ScrollView(props: ScrollViewProps) { return ( <div + ref={rootRef} class={`scroll-view ${local.class || ""}`} style={local.style} onPointerEnter={() => setIsHovered(true)} @@ -209,26 +199,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" diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 56e060633b5..eea9a13e4fa 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -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); } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index f1aee802ec7..3323a9fc667 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -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> diff --git a/packages/ui/src/components/shell-rolling-results.tsx b/packages/ui/src/components/shell-rolling-results.tsx deleted file mode 100644 index 0210e46e0e1..00000000000 --- a/packages/ui/src/components/shell-rolling-results.tsx +++ /dev/null @@ -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> - ) -} diff --git a/packages/ui/src/components/shell-submessage.css b/packages/ui/src/components/shell-submessage.css index 9f19c2d1527..f72ba3fc755 100644 --- a/packages/ui/src/components/shell-submessage.css +++ b/packages/ui/src/components/shell-submessage.css @@ -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; } diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx index cc4149d174c..3d029d97652 100644 --- a/packages/ui/src/components/spinner.tsx +++ b/packages/ui/src/components/spinner.tsx @@ -41,6 +41,7 @@ export function Spinner(props: { animation: square.corner ? undefined : `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`, + "animation-fill-mode": square.corner ? undefined : "both", "animation-delay": square.corner ? undefined : `${square.delay}s`, }} /> diff --git a/packages/ui/src/components/text-reveal.css b/packages/ui/src/components/text-reveal.css index 7939322e6d4..f799962f094 100644 --- a/packages/ui/src/components/text-reveal.css +++ b/packages/ui/src/components/text-reveal.css @@ -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; } } diff --git a/packages/ui/src/components/text-reveal.tsx b/packages/ui/src/components/text-reveal.tsx index edf5dbf837f..c4fe1302f0e 100644 --- a/packages/ui/src/components/text-reveal.tsx +++ b/packages/ui/src/components/text-reveal.tsx @@ -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> - ) -} diff --git a/packages/ui/src/components/text-shimmer.css b/packages/ui/src/components/text-shimmer.css index bd1437c273b..f042dd2d862 100644 --- a/packages/ui/src/components/text-shimmer.css +++ b/packages/ui/src/components/text-shimmer.css @@ -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; } diff --git a/packages/ui/src/components/text-shimmer.tsx b/packages/ui/src/components/text-shimmer.tsx index 0d797e5c1f8..3ab077d92df 100644 --- a/packages/ui/src/components/text-shimmer.tsx +++ b/packages/ui/src/components/text-shimmer.tsx @@ -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"> diff --git a/packages/ui/src/components/text-utils.ts b/packages/ui/src/components/text-utils.ts deleted file mode 100644 index c094b5e65f6..00000000000 --- a/packages/ui/src/components/text-utils.ts +++ /dev/null @@ -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 -} diff --git a/packages/ui/src/components/tool-count-label.css b/packages/ui/src/components/tool-count-label.css index 4ed46e50b5f..11a33ff5d14 100644 --- a/packages/ui/src/components/tool-count-label.css +++ b/packages/ui/src/components/tool-count-label.css @@ -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; } } diff --git a/packages/ui/src/components/tool-count-label.tsx b/packages/ui/src/components/tool-count-label.tsx index c374d2d3762..67e861cdcb3 100644 --- a/packages/ui/src/components/tool-count-label.tsx +++ b/packages/ui/src/components/tool-count-label.tsx @@ -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) diff --git a/packages/ui/src/components/tool-count-summary.css b/packages/ui/src/components/tool-count-summary.css index 435ed95fe60..da8455267cc 100644 --- a/packages/ui/src/components/tool-count-summary.css +++ b/packages/ui/src/components/tool-count-summary.css @@ -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; } diff --git a/packages/ui/src/components/tool-status-title.css b/packages/ui/src/components/tool-status-title.css index 050f5e390a6..d4415bd2daf 100644 --- a/packages/ui/src/components/tool-status-title.css +++ b/packages/ui/src/components/tool-status-title.css @@ -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; } diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 444955af98c..68440b6c637 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -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> diff --git a/packages/ui/src/components/tool-utils.ts b/packages/ui/src/components/tool-utils.ts deleted file mode 100644 index 4d57c626e83..00000000000 --- a/packages/ui/src/components/tool-utils.ts +++ /dev/null @@ -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() - }) -} diff --git a/packages/ui/src/hooks/create-auto-scroll.tsx b/packages/ui/src/hooks/create-auto-scroll.tsx index d36102590b1..3dc520c6213 100644 --- a/packages/ui/src/hooks/create-auto-scroll.tsx +++ b/packages/ui/src/hooks/create-auto-scroll.tsx @@ -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 - } + // 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(), + } - const clearHold = () => { - const next = hold - if (!next) return - if (next.frame !== undefined) cancelAnimationFrame(next.frame) - hold = undefined + if (autoTimer) clearTimeout(autoTimer) + autoTimer = setTimeout(() => { + auto = undefined + autoTimer = undefined + }, 1500) } - const tickHold = () => { - const next = hold - const el = scroll - if (!next || !el) return false - if (Date.now() > next.until) { - clearHold() - return false - } - if (!next.el.isConnected) { - clearHold() - return false - } + const isAuto = (el: HTMLElement) => { + const a = auto + if (!a) return false - const current = next.el.getBoundingClientRect().top - if (!Number.isFinite(current)) { - clearHold() + if (Date.now() - a.time > 1500) { + auto = undefined 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 - } - - 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() - }) + return Math.abs(el.scrollTop - a.top) < 2 } - 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() - } - - const cancelSmooth = () => { - if (scrollAnim) { - scrollAnim.stop() - scrollAnim = undefined - } + // For auto-following content we prefer immediate updates to avoid + // visible "catch up" animations while content is still settling. + scrollToBottomNow("auto") } - 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, } } diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts index 0fcf6f086c1..1c90a2e493d 100644 --- a/packages/ui/src/hooks/index.ts +++ b/packages/ui/src/hooks/index.ts @@ -1,3 +1,2 @@ export * from "./use-filtered-list" export * from "./create-auto-scroll" -export * from "./use-reduced-motion" diff --git a/packages/ui/src/hooks/use-reduced-motion.ts b/packages/ui/src/hooks/use-reduced-motion.ts deleted file mode 100644 index 0038760ec8e..00000000000 --- a/packages/ui/src/hooks/use-reduced-motion.ts +++ /dev/null @@ -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() -}) diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css index f8d11e0e504..f9a09df379e 100644 --- a/packages/ui/src/styles/animations.css +++ b/packages/ui/src/styles/animations.css @@ -26,10 +26,10 @@ @keyframes pulse-opacity-dim { 0%, 100% { - opacity: 0; + opacity: 0.15; } 50% { - opacity: 0.2; + opacity: 0.35; } } diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 213a37c5144..cec42f5a0ca 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -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); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 2637b4a2819..702d1e4e674 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -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); } } diff --git a/packages/ui/src/theme/color.ts b/packages/ui/src/theme/color.ts index f0e15211e95..89d9a653d76 100644 --- a/packages/ui/src/theme/color.ts +++ b/packages/ui/src/theme/color.ts @@ -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), }) } diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index c1c1637d674..cda967697c2 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -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) diff --git a/packages/ui/src/theme/default-themes.ts b/packages/ui/src/theme/default-themes.ts index 52b2b42eba8..b3cfb340c10 100644 --- a/packages/ui/src/theme/default-themes.ts +++ b/packages/ui/src/theme/default-themes.ts @@ -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, diff --git a/packages/ui/src/theme/desktop-theme.schema.json b/packages/ui/src/theme/desktop-theme.schema.json index b60a8f37cad..d4f1ffd21fb 100644 --- a/packages/ui/src/theme/desktop-theme.schema.json +++ b/packages/ui/src/theme/desktop-theme.schema.json @@ -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", diff --git a/packages/ui/src/theme/index.ts b/packages/ui/src/theme/index.ts index d2c60179ec0..bfd55e60b81 100644 --- a/packages/ui/src/theme/index.ts +++ b/packages/ui/src/theme/index.ts @@ -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, diff --git a/packages/ui/src/theme/loader.ts b/packages/ui/src/theme/loader.ts index 0f61076a001..4d48000daf6 100644 --- a/packages/ui/src/theme/loader.ts +++ b/packages/ui/src/theme/loader.ts @@ -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) diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index e8fca41036e..722648dabcd 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -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-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-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-brand-base"] = brandl[8] + tokens["surface-brand-hover"] = brandl[9] + + 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[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] - - 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 (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" + } + + 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-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-interactive-focus"] = (noInk && isDark ? interl : interactive)[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["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" - - 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["icon-diff-modified-base"] = modified() + + 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" + } + + 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), diff --git a/packages/ui/src/theme/themes/aura.json b/packages/ui/src/theme/themes/aura.json index 874939fd4d3..e65eb4b0aa4 100644 --- a/packages/ui/src/theme/themes/aura.json +++ b/packages/ui/src/theme/themes/aura.json @@ -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" } } } diff --git a/packages/ui/src/theme/themes/ayu.json b/packages/ui/src/theme/themes/ayu.json index eac9e0491fc..f4594890355 100644 --- a/packages/ui/src/theme/themes/ayu.json +++ b/packages/ui/src/theme/themes/ayu.json @@ -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" } } } diff --git a/packages/ui/src/theme/themes/carbonfox.json b/packages/ui/src/theme/themes/carbonfox.json index e2fa20d8034..54e55cdeae1 100644 --- a/packages/ui/src/theme/themes/carbonfox.json +++ b/packages/ui/src/theme/themes/carbonfox.json @@ -3,9 +3,11 @@ "name": "Carbonfox", "id": "carbonfox", "light": { - "seeds": { + "palette": { "neutral": "#8e8e8e", + "ink": "#161616", "primary": "#0072c3", + "accent": "#da1e28", "success": "#198038", "warning": "#f1c21b", "error": "#da1e28", @@ -15,44 +17,15 @@ "diffDelete": "#da1e28" }, "overrides": { - "background-base": "#ffffff", - "background-weak": "#f4f4f4", - "background-strong": "#e8e8e8", - "background-stronger": "#dcdcdc", - "surface-raised-strong": "#ffffff", - "surface-raised-stronger": "#ffffff", - "surface-float-base": "#161616", - "surface-float-base-hover": "#262626", - "text-base": "#161616", - "text-weak": "#525252", - "text-strong": "#000000", - "syntax-string": "#198038", - "syntax-primitive": "#da1e28", - "syntax-property": "#0043ce", - "syntax-type": "#007d79", - "syntax-constant": "#6929c4", - "syntax-keyword": "#525252", - "syntax-info": "#0043ce", - "markdown-heading": "#0043ce", - "markdown-text": "#161616", - "markdown-link": "#0043ce", - "markdown-link-text": "#0072c3", - "markdown-code": "#198038", - "markdown-block-quote": "#525252", - "markdown-emph": "#6929c4", - "markdown-strong": "#161616", - "markdown-horizontal-rule": "#c6c6c6", - "markdown-list-item": "#0072c3", - "markdown-list-enumeration": "#0072c3", - "markdown-image": "#0043ce", - "markdown-image-text": "#0072c3", - "markdown-code-block": "#393939" + "syntax-keyword": "#8a3ffc" } }, "dark": { - "seeds": { + "palette": { "neutral": "#393939", + "ink": "#f2f4f8", "primary": "#33b1ff", + "accent": "#ff8389", "success": "#42be65", "warning": "#f1c21b", "error": "#ff8389", @@ -62,61 +35,7 @@ "diffDelete": "#ff8389" }, "overrides": { - "background-base": "#161616", - "background-weak": "#262626", - "background-strong": "#0d0d0d", - "background-stronger": "#000000", - "surface-raised-base": "#1c1c1c", - "surface-raised-base-hover": "#262626", - "surface-raised-strong": "#262626", - "surface-raised-strong-hover": "#303030", - "surface-raised-stronger": "#303030", - "surface-raised-stronger-hover": "#393939", - "surface-raised-stronger-non-alpha": "#303030", - "surface-float-base": "#0d0d0d", - "surface-float-base-hover": "#1a1a1a", - "surface-inset-base": "#0d0d0d", - "surface-inset-strong": "#000000", - "surface-base": "#1e1e1e", - "surface-base-hover": "#262626", - "surface-diff-add-base": "#0e3a22", - "surface-diff-delete-base": "#4d1a1f", - "input-base": "#262626", - "input-hover": "#303030", - "button-secondary-base": "#393939", - "button-secondary-hover": "#4c4c4c", - "border-weak-base": "#393939", - "border-weak-hover": "#4c4c4c", - "border-base": "#525252", - "border-hover": "#636363", - "border-strong-base": "#6f6f6f", - "text-base": "#f2f4f8", - "text-weak": "#8d8d8d", - "text-weaker": "#6f6f6f", - "text-strong": "#ffffff", - "icon-base": "#8d8d8d", - "icon-weak-base": "#6f6f6f", - "syntax-string": "#42be65", - "syntax-primitive": "#ff8389", - "syntax-property": "#78a9ff", - "syntax-type": "#08bdba", - "syntax-constant": "#be95ff", - "syntax-keyword": "#8d8d8d", - "syntax-info": "#78a9ff", - "markdown-heading": "#82cfff", - "markdown-text": "#f2f4f8", - "markdown-link": "#78a9ff", - "markdown-link-text": "#33b1ff", - "markdown-code": "#42be65", - "markdown-block-quote": "#8d8d8d", - "markdown-emph": "#be95ff", - "markdown-strong": "#ffffff", - "markdown-horizontal-rule": "#393939", - "markdown-list-item": "#33b1ff", - "markdown-list-enumeration": "#33b1ff", - "markdown-image": "#78a9ff", - "markdown-image-text": "#33b1ff", - "markdown-code-block": "#c6c6c6" + "syntax-keyword": "#be95ff" } } } diff --git a/packages/ui/src/theme/themes/catppuccin.json b/packages/ui/src/theme/themes/catppuccin.json index 2a32df09846..66fd37e26ba 100644 --- a/packages/ui/src/theme/themes/catppuccin.json +++ b/packages/ui/src/theme/themes/catppuccin.json @@ -3,129 +3,39 @@ "name": "Catppuccin", "id": "catppuccin", "light": { - "seeds": { + "palette": { "neutral": "#f5e0dc", + "ink": "#4c4f69", "primary": "#7287fd", + "accent": "#d20f39", "success": "#40a02b", "warning": "#df8e1d", "error": "#d20f39", "info": "#04a5e5", - "interactive": "#7287fd", "diffAdd": "#a6d189", "diffDelete": "#e78284" }, "overrides": { - "background-base": "#f5e0dc", - "background-weak": "#f2d8d4", - "background-strong": "#f9e8e4", - "background-stronger": "#fdeeee", - "border-weak-base": "#e0cfd3", - "border-weak-hover": "#d6c4c8", - "border-weak-active": "#cdb9be", - "border-weak-selected": "#c2aeb4", - "border-weak-disabled": "#fbeff2", - "border-weak-focus": "#c7b4ba", - "border-base": "#bca6b2", - "border-hover": "#b19ca8", - "border-active": "#a6929e", - "border-selected": "#9a8894", - "border-disabled": "#f3e4e7", - "border-focus": "#ab97a1", - "border-strong-base": "#83677f", - "border-strong-hover": "#775b73", - "border-strong-active": "#6b5068", - "border-strong-selected": "#5f465d", - "border-strong-disabled": "#d9c5cf", - "border-strong-focus": "#714f66", - "surface-diff-add-base": "#edf5e6", - "surface-diff-delete-base": "#fde1e3", - "surface-diff-hidden-base": "#e4e2f6", - "text-base": "#4c4f69", - "text-weak": "#6c6f85", - "text-strong": "#1f1f2a", - "syntax-string": "#40a02b", - "syntax-primitive": "#d20f39", - "syntax-property": "#7287fd", - "syntax-type": "#df8e1d", - "syntax-constant": "#04a5e5", - "syntax-info": "#04a5e5", - "markdown-heading": "#7287fd", - "markdown-text": "#4c4f69", - "markdown-link": "#7287fd", - "markdown-link-text": "#04a5e5", - "markdown-code": "#40a02b", - "markdown-block-quote": "#df8e1d", - "markdown-emph": "#df8e1d", - "markdown-strong": "#d20f39", - "markdown-horizontal-rule": "#d4c5cf", - "markdown-list-item": "#7287fd", - "markdown-list-enumeration": "#04a5e5", - "markdown-image": "#7287fd", - "markdown-image-text": "#04a5e5", - "markdown-code-block": "#7287fd" + "syntax-keyword": "#8839ef", + "syntax-primitive": "#fe640b" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1e1e2e", + "ink": "#cdd6f4", "primary": "#b4befe", + "accent": "#f38ba8", "success": "#a6d189", "warning": "#f4b8e4", "error": "#f38ba8", "info": "#89dceb", - "interactive": "#b4befe", "diffAdd": "#94e2d5", "diffDelete": "#f38ba8" }, "overrides": { - "background-base": "#1e1e2e", - "background-weak": "#211f31", - "background-strong": "#1c1c29", - "background-stronger": "#191926", - "border-weak-base": "#35324a", - "border-weak-hover": "#393655", - "border-weak-active": "#403c61", - "border-weak-selected": "#47436d", - "border-weak-disabled": "#141426", - "border-weak-focus": "#3d3a63", - "border-base": "#4a4763", - "border-hover": "#524f70", - "border-active": "#5a577d", - "border-selected": "#625f8a", - "border-disabled": "#1b1a2c", - "border-focus": "#575379", - "border-strong-base": "#6e6a8c", - "border-strong-hover": "#787497", - "border-strong-active": "#8380a2", - "border-strong-selected": "#8d8bad", - "border-strong-disabled": "#232237", - "border-strong-focus": "#7b779b", - "surface-diff-add-base": "#1d2c30", - "surface-diff-delete-base": "#2c1f2a", - "surface-diff-hidden-base": "#232538", - "text-base": "#cdd6f4", - "text-weak": "#a6adc8", - "text-strong": "#f4f2ff", - "syntax-string": "#a6e3a1", - "syntax-primitive": "#f38ba8", - "syntax-property": "#b4befe", - "syntax-type": "#f9e2af", - "syntax-constant": "#89dceb", - "syntax-info": "#89dceb", - "markdown-heading": "#b4befe", - "markdown-text": "#cdd6f4", - "markdown-link": "#b4befe", - "markdown-link-text": "#89dceb", - "markdown-code": "#a6e3a1", - "markdown-block-quote": "#f9e2af", - "markdown-emph": "#f9e2af", - "markdown-strong": "#f38ba8", - "markdown-horizontal-rule": "#2e2d45", - "markdown-list-item": "#b4befe", - "markdown-list-enumeration": "#89dceb", - "markdown-image": "#b4befe", - "markdown-image-text": "#89dceb", - "markdown-code-block": "#cdd6f4" + "syntax-keyword": "#cba6f7", + "syntax-primitive": "#fab387" } } } diff --git a/packages/ui/src/theme/themes/dracula.json b/packages/ui/src/theme/themes/dracula.json index 696f1060c70..495042ca7b0 100644 --- a/packages/ui/src/theme/themes/dracula.json +++ b/packages/ui/src/theme/themes/dracula.json @@ -3,129 +3,41 @@ "name": "Dracula", "id": "dracula", "light": { - "seeds": { + "palette": { "neutral": "#f8f8f2", + "ink": "#1f1f2f", "primary": "#7c6bf5", + "accent": "#d16090", "success": "#2fbf71", "warning": "#f7a14d", "error": "#d9536f", "info": "#1d7fc5", - "interactive": "#7c6bf5", "diffAdd": "#9fe3b3", "diffDelete": "#f8a1b8" }, "overrides": { - "background-base": "#f8f8f2", - "background-weak": "#f1f2ed", - "background-strong": "#f6f6f1", - "background-stronger": "#f2f2ec", - "border-weak-base": "#e2e3da", - "border-weak-hover": "#d8d9d0", - "border-weak-active": "#cfd0c7", - "border-weak-selected": "#c4c6bc", - "border-weak-disabled": "#eceee3", - "border-weak-focus": "#c9cabf", - "border-base": "#c4c6ba", - "border-hover": "#b8baae", - "border-active": "#abada3", - "border-selected": "#979a90", - "border-disabled": "#e5e7dd", - "border-focus": "#b0b2a7", - "border-strong-base": "#9fa293", - "border-strong-hover": "#8e9185", - "border-strong-active": "#7e8176", - "border-strong-selected": "#6f7268", - "border-strong-disabled": "#c7c9be", - "border-strong-focus": "#878b7f", - "surface-diff-add-base": "#e4f5e6", - "surface-diff-delete-base": "#fae4eb", - "surface-diff-hidden-base": "#dedfe9", - "text-base": "#1f1f2f", - "text-weak": "#52526b", - "text-strong": "#05040c", - "syntax-string": "#2fbf71", - "syntax-primitive": "#d16090", - "syntax-property": "#7c6bf5", - "syntax-type": "#f7a14d", - "syntax-constant": "#1d7fc5", - "syntax-info": "#1d7fc5", - "markdown-heading": "#7c6bf5", - "markdown-text": "#1f1f2f", - "markdown-link": "#7c6bf5", - "markdown-link-text": "#1d7fc5", - "markdown-code": "#2fbf71", - "markdown-block-quote": "#f7a14d", - "markdown-emph": "#f7a14d", - "markdown-strong": "#d16090", - "markdown-horizontal-rule": "#c3c5d4", - "markdown-list-item": "#7c6bf5", - "markdown-list-enumeration": "#1d7fc5", - "markdown-image": "#7c6bf5", - "markdown-image-text": "#1d7fc5", - "markdown-code-block": "#1d7fc5" + "syntax-keyword": "#d16090", + "syntax-string": "#596600", + "syntax-primitive": "#7c6bf5" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1d1e28", + "ink": "#f8f8f2", "primary": "#bd93f9", + "accent": "#ff79c6", "success": "#50fa7b", "warning": "#ffb86c", "error": "#ff5555", "info": "#8be9fd", - "interactive": "#bd93f9", "diffAdd": "#2fb27d", "diffDelete": "#ff6b81" }, "overrides": { - "background-base": "#14151f", - "background-weak": "#181926", - "background-strong": "#161722", - "background-stronger": "#191a26", - "border-weak-base": "#2d2f3c", - "border-weak-hover": "#303244", - "border-weak-active": "#35364c", - "border-weak-selected": "#3b3d55", - "border-weak-disabled": "#1e1f2b", - "border-weak-focus": "#383a50", - "border-base": "#3f415a", - "border-hover": "#464967", - "border-active": "#4d5073", - "border-selected": "#55587f", - "border-disabled": "#272834", - "border-focus": "#4a4d6d", - "border-strong-base": "#606488", - "border-strong-hover": "#6a6e96", - "border-strong-active": "#7378a3", - "border-strong-selected": "#7d82b1", - "border-strong-disabled": "#343649", - "border-strong-focus": "#6f739c", - "surface-diff-add-base": "#1f2a2f", - "surface-diff-delete-base": "#2d1f27", - "surface-diff-hidden-base": "#24253a", - "text-base": "#f8f8f2", - "text-weak": "#b6b9e4", - "text-strong": "#ffffff", - "syntax-string": "#50fa7b", - "syntax-primitive": "#ff79c6", - "syntax-property": "#bd93f9", - "syntax-type": "#ffb86c", - "syntax-constant": "#8be9fd", - "syntax-info": "#8be9fd", - "markdown-heading": "#bd93f9", - "markdown-text": "#f8f8f2", - "markdown-link": "#bd93f9", - "markdown-link-text": "#8be9fd", - "markdown-code": "#50fa7b", - "markdown-block-quote": "#ffb86c", - "markdown-emph": "#ffb86c", - "markdown-strong": "#ff79c6", - "markdown-horizontal-rule": "#44475a", - "markdown-list-item": "#bd93f9", - "markdown-list-enumeration": "#8be9fd", - "markdown-image": "#bd93f9", - "markdown-image-text": "#8be9fd", - "markdown-code-block": "#f8f8f2" + "syntax-keyword": "#ff79c6", + "syntax-string": "#f1fa8c", + "syntax-primitive": "#bd93f9" } } } diff --git a/packages/ui/src/theme/themes/gruvbox.json b/packages/ui/src/theme/themes/gruvbox.json index cf87ccd5532..f078db2d4c2 100644 --- a/packages/ui/src/theme/themes/gruvbox.json +++ b/packages/ui/src/theme/themes/gruvbox.json @@ -3,130 +3,39 @@ "name": "Gruvbox", "id": "gruvbox", "light": { - "seeds": { + "palette": { "neutral": "#fbf1c7", + "ink": "#3c3836", "primary": "#076678", + "accent": "#9d0006", "success": "#79740e", "warning": "#b57614", "error": "#9d0006", "info": "#8f3f71", - "interactive": "#076678", "diffAdd": "#79740e", "diffDelete": "#9d0006" }, "overrides": { - "background-base": "#fbf1c7", - "background-weak": "#f2e5bc", - "background-strong": "#f9f5d7", - "background-stronger": "#fdf9e8", - "surface-raised-stronger-non-alpha": "#fbfaf5", - "border-weak-base": "#d5c4a1", - "border-weak-hover": "#c9b897", - "border-weak-active": "#bdae93", - "border-weak-selected": "#b0a285", - "border-weak-disabled": "#f0e4b8", - "border-weak-focus": "#c4b590", - "border-base": "#bdae93", - "border-hover": "#b0a285", - "border-active": "#a89984", - "border-selected": "#928374", - "border-disabled": "#e5d9ad", - "border-focus": "#a89984", - "border-strong-base": "#7c6f64", - "border-strong-hover": "#6e6259", - "border-strong-active": "#665c54", - "border-strong-selected": "#5a524b", - "border-strong-disabled": "#c9bda1", - "border-strong-focus": "#665c54", - "surface-diff-add-base": "#dde3b1", - "surface-diff-delete-base": "#e8c7c3", - "surface-diff-hidden-base": "#ebdfb5", - "text-base": "#3c3836", - "text-weak": "#7c6f64", - "text-strong": "#282828", - "syntax-string": "#79740e", - "syntax-primitive": "#9d0006", - "syntax-property": "#076678", - "syntax-type": "#b57614", - "syntax-constant": "#8f3f71", - "syntax-info": "#427b58", - "markdown-heading": "#076678", - "markdown-text": "#3c3836", - "markdown-link": "#076678", - "markdown-link-text": "#427b58", - "markdown-code": "#79740e", - "markdown-block-quote": "#928374", - "markdown-emph": "#8f3f71", - "markdown-strong": "#af3a03", - "markdown-horizontal-rule": "#d5c4a1", - "markdown-list-item": "#076678", - "markdown-list-enumeration": "#427b58", - "markdown-image": "#076678", - "markdown-image-text": "#427b58", - "markdown-code-block": "#3c3836" + "syntax-keyword": "#9d0006", + "syntax-primitive": "#8f3f71" } }, "dark": { - "seeds": { + "palette": { "neutral": "#282828", + "ink": "#ebdbb2", "primary": "#83a598", + "accent": "#fb4934", "success": "#b8bb26", "warning": "#fabd2f", "error": "#fb4934", "info": "#d3869b", - "interactive": "#83a598", "diffAdd": "#b8bb26", "diffDelete": "#fb4934" }, "overrides": { - "background-base": "#282828", - "background-weak": "#32302f", - "background-strong": "#1d2021", - "background-stronger": "#141617", - "border-weak-base": "#504945", - "border-weak-hover": "#5a524b", - "border-weak-active": "#665c54", - "border-weak-selected": "#70665d", - "border-weak-disabled": "#1e1d1c", - "border-weak-focus": "#5e5650", - "border-base": "#665c54", - "border-hover": "#70665d", - "border-active": "#7c6f64", - "border-selected": "#928374", - "border-disabled": "#2a2827", - "border-focus": "#7c6f64", - "border-strong-base": "#928374", - "border-strong-hover": "#9d8e7f", - "border-strong-active": "#a89984", - "border-strong-selected": "#b3a48f", - "border-strong-disabled": "#3c3836", - "border-strong-focus": "#a89984", - "surface-diff-add-base": "#2a3325", - "surface-diff-delete-base": "#3c2222", - "surface-diff-hidden-base": "#32302f", - "text-base": "#ebdbb2", - "text-weak": "#a89984", - "text-strong": "#fbf1c7", - "syntax-string": "#b8bb26", - "syntax-primitive": "#fb4934", - "syntax-property": "#83a598", - "syntax-type": "#fabd2f", - "syntax-constant": "#d3869b", - "syntax-info": "#8ec07c", - "markdown-heading": "#83a598", - "markdown-text": "#ebdbb2", - "markdown-link": "#83a598", - "markdown-link-text": "#8ec07c", - "markdown-code": "#b8bb26", - "markdown-block-quote": "#928374", - "markdown-emph": "#d3869b", - "markdown-strong": "#fe8019", - "markdown-horizontal-rule": "#504945", - "markdown-list-item": "#83a598", - "markdown-list-enumeration": "#8ec07c", - "markdown-image": "#83a598", - "markdown-image-text": "#8ec07c", - "markdown-code-block": "#ebdbb2" + "syntax-keyword": "#fb4934", + "syntax-primitive": "#d3869b" } } } diff --git a/packages/ui/src/theme/themes/monokai.json b/packages/ui/src/theme/themes/monokai.json index d49846ddb3e..3a2656b6ea1 100644 --- a/packages/ui/src/theme/themes/monokai.json +++ b/packages/ui/src/theme/themes/monokai.json @@ -3,129 +3,41 @@ "name": "Monokai", "id": "monokai", "light": { - "seeds": { + "palette": { "neutral": "#fdf8ec", + "ink": "#292318", "primary": "#bf7bff", + "accent": "#d9487c", "success": "#4fb54b", "warning": "#f1a948", "error": "#e54b4b", "info": "#2d9ad7", - "interactive": "#bf7bff", "diffAdd": "#bfe7a3", "diffDelete": "#f6a3ae" }, "overrides": { - "background-base": "#fdf8ec", - "background-weak": "#f8f2e6", - "background-strong": "#fbf5e8", - "background-stronger": "#f7efdd", - "border-weak-base": "#e9e0cf", - "border-weak-hover": "#dfd5c3", - "border-weak-active": "#d5cab7", - "border-weak-selected": "#cabfad", - "border-weak-disabled": "#f3ebdd", - "border-weak-focus": "#d0c2b1", - "border-base": "#c7b9a5", - "border-hover": "#bcae98", - "border-active": "#b0a28c", - "border-selected": "#a49781", - "border-disabled": "#efe5d6", - "border-focus": "#b6a893", - "border-strong-base": "#998b76", - "border-strong-hover": "#8a7c67", - "border-strong-active": "#7a6d58", - "border-strong-selected": "#6c604c", - "border-strong-disabled": "#d7cabc", - "border-strong-focus": "#82745f", - "surface-diff-add-base": "#e8f7e1", - "surface-diff-delete-base": "#fde5e4", - "surface-diff-hidden-base": "#e9e0d0", - "text-base": "#292318", - "text-weak": "#6d5c40", - "text-strong": "#1c150c", - "syntax-string": "#4fb54b", - "syntax-primitive": "#d9487c", - "syntax-property": "#bf7bff", - "syntax-type": "#f1a948", - "syntax-constant": "#2d9ad7", - "syntax-info": "#2d9ad7", - "markdown-heading": "#bf7bff", - "markdown-text": "#292318", - "markdown-link": "#bf7bff", - "markdown-link-text": "#2d9ad7", - "markdown-code": "#4fb54b", - "markdown-block-quote": "#f1a948", - "markdown-emph": "#f1a948", - "markdown-strong": "#d9487c", - "markdown-horizontal-rule": "#cdbdab", - "markdown-list-item": "#bf7bff", - "markdown-list-enumeration": "#2d9ad7", - "markdown-image": "#bf7bff", - "markdown-image-text": "#2d9ad7", - "markdown-code-block": "#2d9ad7" + "syntax-keyword": "#d9487c", + "syntax-string": "#8a6500", + "syntax-primitive": "#bf7bff" } }, "dark": { - "seeds": { + "palette": { "neutral": "#272822", + "ink": "#f8f8f2", "primary": "#ae81ff", + "accent": "#f92672", "success": "#a6e22e", "warning": "#fd971f", "error": "#f92672", "info": "#66d9ef", - "interactive": "#ae81ff", "diffAdd": "#4d7f2a", "diffDelete": "#f4477c" }, "overrides": { - "background-base": "#23241e", - "background-weak": "#27281f", - "background-strong": "#25261f", - "background-stronger": "#292a23", - "border-weak-base": "#343528", - "border-weak-hover": "#393a2d", - "border-weak-active": "#3f4033", - "border-weak-selected": "#454639", - "border-weak-disabled": "#1d1e16", - "border-weak-focus": "#414235", - "border-base": "#494a3a", - "border-hover": "#50523f", - "border-active": "#585a45", - "border-selected": "#60624b", - "border-disabled": "#23241b", - "border-focus": "#555741", - "border-strong-base": "#6a6c55", - "border-strong-hover": "#73755d", - "border-strong-active": "#7d7f66", - "border-strong-selected": "#878970", - "border-strong-disabled": "#2c2d23", - "border-strong-focus": "#7a7c63", - "surface-diff-add-base": "#1e2a1d", - "surface-diff-delete-base": "#301c24", - "surface-diff-hidden-base": "#2f2f24", - "text-base": "#f8f8f2", - "text-weak": "#c5c5c0", - "text-strong": "#ffffff", - "syntax-string": "#a6e22e", - "syntax-primitive": "#f92672", - "syntax-property": "#ae81ff", - "syntax-type": "#fd971f", - "syntax-constant": "#66d9ef", - "syntax-info": "#66d9ef", - "markdown-heading": "#ae81ff", - "markdown-text": "#f8f8f2", - "markdown-link": "#ae81ff", - "markdown-link-text": "#66d9ef", - "markdown-code": "#a6e22e", - "markdown-block-quote": "#fd971f", - "markdown-emph": "#fd971f", - "markdown-strong": "#f92672", - "markdown-horizontal-rule": "#3b3c34", - "markdown-list-item": "#ae81ff", - "markdown-list-enumeration": "#66d9ef", - "markdown-image": "#ae81ff", - "markdown-image-text": "#66d9ef", - "markdown-code-block": "#f8f8f2" + "syntax-keyword": "#f92672", + "syntax-string": "#e6db74", + "syntax-primitive": "#ae81ff" } } } diff --git a/packages/ui/src/theme/themes/nightowl.json b/packages/ui/src/theme/themes/nightowl.json index 5b0331e5fd5..d6b4d4dad21 100644 --- a/packages/ui/src/theme/themes/nightowl.json +++ b/packages/ui/src/theme/themes/nightowl.json @@ -3,129 +3,40 @@ "name": "Night Owl", "id": "nightowl", "light": { - "seeds": { + "palette": { "neutral": "#f0f0f0", + "ink": "#403f53", "primary": "#4876d6", + "accent": "#aa0982", "success": "#2aa298", "warning": "#c96765", "error": "#de3d3b", "info": "#4876d6", - "interactive": "#4876d6", "diffAdd": "#2aa298", "diffDelete": "#de3d3b" }, "overrides": { - "background-base": "#fbfbfb", - "background-weak": "#f0f0f0", - "background-strong": "#ffffff", - "background-stronger": "#ffffff", - "border-weak-base": "#d9d9d9", - "border-weak-hover": "#cccccc", - "border-weak-active": "#bfbfbf", - "border-weak-selected": "#4876d6", - "border-weak-disabled": "#e6e6e6", - "border-weak-focus": "#4876d6", - "border-base": "#c0c0c0", - "border-hover": "#b3b3b3", - "border-active": "#a6a6a6", - "border-selected": "#4876d6", - "border-disabled": "#d9d9d9", - "border-focus": "#4876d6", - "border-strong-base": "#90a7b2", - "border-strong-hover": "#7d9aa6", - "border-strong-active": "#6a8d9a", - "border-strong-selected": "#4876d6", - "border-strong-disabled": "#c0c0c0", - "border-strong-focus": "#4876d6", - "surface-diff-add-base": "#eaf8f6", - "surface-diff-delete-base": "#fbe9e9", - "surface-diff-hidden-base": "#e8f0fc", - "text-base": "#403f53", - "text-weak": "#7a8181", - "text-strong": "#1a1a1a", - "syntax-string": "#c96765", - "syntax-primitive": "#aa0982", - "syntax-property": "#4876d6", - "syntax-type": "#994cc3", - "syntax-constant": "#2aa298", - "syntax-info": "#4876d6", - "markdown-heading": "#4876d6", - "markdown-text": "#403f53", - "markdown-link": "#4876d6", - "markdown-link-text": "#2aa298", - "markdown-code": "#2aa298", - "markdown-block-quote": "#7a8181", - "markdown-emph": "#994cc3", - "markdown-strong": "#c96765", - "markdown-horizontal-rule": "#90a7b2", - "markdown-list-item": "#4876d6", - "markdown-list-enumeration": "#2aa298", - "markdown-image": "#4876d6", - "markdown-image-text": "#2aa298", - "markdown-code-block": "#403f53" + "syntax-keyword": "#994cc3" } }, "dark": { - "seeds": { + "palette": { "neutral": "#011627", + "ink": "#d6deeb", "primary": "#82aaff", + "accent": "#f78c6c", "success": "#c5e478", "warning": "#ecc48d", "error": "#ef5350", "info": "#82aaff", - "interactive": "#82aaff", "diffAdd": "#c5e478", "diffDelete": "#ef5350" }, "overrides": { - "background-base": "#011627", - "background-weak": "#0b253a", - "background-strong": "#001122", - "background-stronger": "#000c17", - "border-weak-base": "#1d3b53", - "border-weak-hover": "#234561", - "border-weak-active": "#2a506f", - "border-weak-selected": "#82aaff", - "border-weak-disabled": "#0f2132", - "border-weak-focus": "#82aaff", - "border-base": "#3a5a75", - "border-hover": "#456785", - "border-active": "#507494", - "border-selected": "#82aaff", - "border-disabled": "#1a3347", - "border-focus": "#82aaff", - "border-strong-base": "#5f7e97", - "border-strong-hover": "#6e8da6", - "border-strong-active": "#7d9cb5", - "border-strong-selected": "#82aaff", - "border-strong-disabled": "#2c4a63", - "border-strong-focus": "#82aaff", - "surface-diff-add-base": "#0a2e1a", - "surface-diff-delete-base": "#2d1b1b", - "surface-diff-hidden-base": "#0b253a", - "text-base": "#d6deeb", - "text-weak": "#5f7e97", - "text-strong": "#ffffff", + "syntax-comment": "#637777", + "syntax-keyword": "#c792ea", "syntax-string": "#ecc48d", - "syntax-primitive": "#f78c6c", - "syntax-property": "#82aaff", - "syntax-type": "#c5e478", - "syntax-constant": "#7fdbca", - "syntax-info": "#82aaff", - "markdown-heading": "#82aaff", - "markdown-text": "#d6deeb", - "markdown-link": "#82aaff", - "markdown-link-text": "#7fdbca", - "markdown-code": "#c5e478", - "markdown-block-quote": "#5f7e97", - "markdown-emph": "#c792ea", - "markdown-strong": "#ecc48d", - "markdown-horizontal-rule": "#5f7e97", - "markdown-list-item": "#82aaff", - "markdown-list-enumeration": "#7fdbca", - "markdown-image": "#82aaff", - "markdown-image-text": "#7fdbca", - "markdown-code-block": "#d6deeb" + "syntax-primitive": "#f78c6c" } } } diff --git a/packages/ui/src/theme/themes/nord.json b/packages/ui/src/theme/themes/nord.json index 44378de06ab..05ec4672ec5 100644 --- a/packages/ui/src/theme/themes/nord.json +++ b/packages/ui/src/theme/themes/nord.json @@ -3,129 +3,40 @@ "name": "Nord", "id": "nord", "light": { - "seeds": { + "palette": { "neutral": "#eceff4", + "ink": "#2e3440", "primary": "#5e81ac", + "accent": "#bf616a", "success": "#8fbcbb", "warning": "#d08770", "error": "#bf616a", "info": "#81a1c1", - "interactive": "#5e81ac", "diffAdd": "#a3be8c", "diffDelete": "#bf616a" }, "overrides": { - "background-base": "#eceff4", - "background-weak": "#e4e8f0", - "background-strong": "#f1f3f8", - "background-stronger": "#f6f8fc", - "border-weak-base": "#d5dbe7", - "border-weak-hover": "#c9d0de", - "border-weak-active": "#bec5d4", - "border-weak-selected": "#b2bacc", - "border-weak-disabled": "#f0f3fa", - "border-weak-focus": "#b9bfd0", - "border-base": "#afb7cb", - "border-hover": "#a3abc1", - "border-active": "#979fb7", - "border-selected": "#8b94ad", - "border-disabled": "#e5e9f2", - "border-focus": "#9ca4ba", - "border-strong-base": "#757f97", - "border-strong-hover": "#69718a", - "border-strong-active": "#5d647d", - "border-strong-selected": "#525970", - "border-strong-disabled": "#c9cedc", - "border-strong-focus": "#636c84", - "surface-diff-add-base": "#e4f0e4", - "surface-diff-delete-base": "#f4e1e4", - "surface-diff-hidden-base": "#dfe6f2", - "text-base": "#2e3440", - "text-weak": "#4c566a", - "text-strong": "#1f2530", + "syntax-keyword": "#5e81ac", "syntax-string": "#a3be8c", - "syntax-primitive": "#bf616a", - "syntax-property": "#5e81ac", - "syntax-type": "#d08770", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#5e81ac", - "markdown-text": "#2e3440", - "markdown-link": "#5e81ac", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#cbd3e1", - "markdown-list-item": "#5e81ac", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#5e81ac", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#5e81ac" + "syntax-primitive": "#b48ead" } }, "dark": { - "seeds": { + "palette": { "neutral": "#2e3440", + "ink": "#e5e9f0", "primary": "#88c0d0", + "accent": "#d57780", "success": "#a3be8c", "warning": "#d08770", "error": "#bf616a", "info": "#81a1c1", - "interactive": "#88c0d0", "diffAdd": "#81a1c1", "diffDelete": "#bf616a" }, "overrides": { - "background-base": "#1f2430", - "background-weak": "#222938", - "background-strong": "#1c202a", - "background-stronger": "#181c24", - "border-weak-base": "#343a47", - "border-weak-hover": "#383f50", - "border-weak-active": "#3d4458", - "border-weak-selected": "#434a62", - "border-weak-disabled": "#151923", - "border-weak-focus": "#3f4359", - "border-base": "#4a5163", - "border-hover": "#515870", - "border-active": "#585f7c", - "border-selected": "#606889", - "border-disabled": "#1b202a", - "border-focus": "#545b78", - "border-strong-base": "#6a7492", - "border-strong-hover": "#747e9f", - "border-strong-active": "#7e88ac", - "border-strong-selected": "#8993b9", - "border-strong-disabled": "#232836", - "border-strong-focus": "#76819f", - "surface-diff-add-base": "#1f2e33", - "surface-diff-delete-base": "#2e212a", - "surface-diff-hidden-base": "#222b3a", - "text-base": "#e5e9f0", - "text-weak": "#a4adbf", - "text-strong": "#f8fafc", - "syntax-string": "#a3be8c", - "syntax-primitive": "#d57780", - "syntax-property": "#88c0d0", - "syntax-type": "#eac196", - "syntax-constant": "#81a1c1", - "syntax-info": "#81a1c1", - "markdown-heading": "#88c0d0", - "markdown-text": "#e5e9f0", - "markdown-link": "#88c0d0", - "markdown-link-text": "#81a1c1", - "markdown-code": "#a3be8c", - "markdown-block-quote": "#d08770", - "markdown-emph": "#d08770", - "markdown-strong": "#bf616a", - "markdown-horizontal-rule": "#2f384a", - "markdown-list-item": "#88c0d0", - "markdown-list-enumeration": "#81a1c1", - "markdown-image": "#88c0d0", - "markdown-image-text": "#81a1c1", - "markdown-code-block": "#cbd3e1" + "syntax-keyword": "#81a1c1", + "syntax-primitive": "#b48ead" } } } diff --git a/packages/ui/src/theme/themes/oc-1.json b/packages/ui/src/theme/themes/oc-1.json deleted file mode 100644 index 132825e3fe8..00000000000 --- a/packages/ui/src/theme/themes/oc-1.json +++ /dev/null @@ -1,527 +0,0 @@ -{ - "$schema": "https://opencode.ai/desktop-theme.json", - "name": "OC-1", - "id": "oc-1", - "light": { - "seeds": { - "neutral": "#8e8b8b", - "primary": "#dcde8d", - "success": "#12c905", - "warning": "#ffdc17", - "error": "#fc533a", - "info": "#a753ae", - "interactive": "#034cff", - "diffAdd": "#9ff29a", - "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "#f8f7f7", - "background-weak": "var(--smoke-light-3)", - "background-strong": "var(--smoke-light-1)", - "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-2)", - "surface-float-base": "var(--smoke-dark-1)", - "surface-float-base-hover": "var(--smoke-dark-2)", - "surface-raised-base-hover": "var(--smoke-light-alpha-3)", - "surface-raised-base-active": "var(--smoke-light-alpha-4)", - "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-strong": "#ffffff", - "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-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-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)", - "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-strong-hover": "#151313", - "icon-strong-active": "#020202", - "icon-strong-selected": "#020202", - "icon-strong-disabled": "var(--smoke-light-8)", - "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-7)", - "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)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#006656", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#fb4804", - "syntax-operator": "var(--text-base)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ed6dc8", - "syntax-type": "#596600", - "syntax-constant": "#007b80", - "syntax-punctuation": "var(--text-base)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-light-10)", - "syntax-warning": "var(--amber-light-10)", - "syntax-critical": "var(--ember-light-10)", - "syntax-info": "#0092a8", - "syntax-diff-add": "var(--mint-light-11)", - "syntax-diff-delete": "var(--ember-light-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#d68c27", - "markdown-text": "#1a1a1a", - "markdown-link": "#3b7dd8", - "markdown-link-text": "#318795", - "markdown-code": "#3d9a57", - "markdown-block-quote": "#b0851f", - "markdown-emph": "#b0851f", - "markdown-strong": "#d68c27", - "markdown-horizontal-rule": "#8a8a8a", - "markdown-list-item": "#3b7dd8", - "markdown-list-enumeration": "#318795", - "markdown-image": "#3b7dd8", - "markdown-image-text": "#318795", - "markdown-code-block": "#1a1a1a", - "border-color": "#ffffff", - "border-weaker-base": "var(--smoke-light-alpha-3)", - "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", - "avatar-background-purple": "#f9f1fe", - "avatar-background-cyan": "#e7f9fb", - "avatar-background-lime": "#eefadc", - "avatar-text-pink": "#cd1d8d", - "avatar-text-mint": "#147d6f", - "avatar-text-orange": "#ed5f00", - "avatar-text-purple": "#8445bc", - "avatar-text-cyan": "#0894b3", - "avatar-text-lime": "#5d770d" - } - }, - "dark": { - "seeds": { - "neutral": "#716c6b", - "primary": "#fab283", - "success": "#12c905", - "warning": "#fcd53a", - "error": "#fc533a", - "info": "#edb2f1", - "interactive": "#034cff", - "diffAdd": "#c8ffc4", - "diffDelete": "#fc533a" - }, - "overrides": { - "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)", - "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-dark-3)", - "surface-success-weak": "var(--apple-dark-2)", - "surface-success-strong": "var(--apple-dark-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)", - "icon-strong-hover": "#f6f3f3", - "icon-strong-active": "#fcfcfc", - "icon-strong-selected": "#fdfcfc", - "icon-strong-disabled": "var(--smoke-dark-8)", - "icon-strong-focus": "#fdfcfc", - "icon-brand-base": "var(--white)", - "icon-interactive-base": "var(--cobalt-dark-11)", - "icon-success-base": "var(--apple-dark-9)", - "icon-success-hover": "var(--apple-dark-10)", - "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)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#00ceb9", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#ffba92", - "syntax-operator": "var(--text-weak)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ff9ae2", - "syntax-type": "#ecf58c", - "syntax-constant": "#93e9f6", - "syntax-punctuation": "var(--text-weak)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-dark-10)", - "syntax-warning": "var(--amber-dark-10)", - "syntax-critical": "var(--ember-dark-10)", - "syntax-info": "#93e9f6", - "syntax-diff-add": "var(--mint-dark-11)", - "syntax-diff-delete": "var(--ember-dark-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#9d7cd8", - "markdown-text": "#eeeeee", - "markdown-link": "#fab283", - "markdown-link-text": "#56b6c2", - "markdown-code": "#7fd88f", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#f5a742", - "markdown-horizontal-rule": "#808080", - "markdown-list-item": "#fab283", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#fab283", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#eeeeee", - "border-color": "#ffffff", - "border-weaker-base": "var(--smoke-dark-alpha-3)", - "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", - "avatar-background-purple": "#432155", - "avatar-background-cyan": "#0f3058", - "avatar-background-lime": "#2b3711", - "avatar-text-pink": "#e34ba9", - "avatar-text-mint": "#95f3d9", - "avatar-text-orange": "#ff802b", - "avatar-text-purple": "#9d5bd2", - "avatar-text-cyan": "#369eff", - "avatar-text-lime": "#c4f042" - } - } -} diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index 73ca57da9d8..fdf0c2caf44 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -3,7 +3,7 @@ "name": "OC-2", "id": "oc-2", "light": { - "seeds": { + "palette": { "neutral": "#8f8f8f", "primary": "#dcde8d", "success": "#12c905", @@ -13,258 +13,10 @@ "interactive": "#034cff", "diffAdd": "#9ff29a", "diffDelete": "#fc533a" - }, - "overrides": { - "background-base": "#f8f8f8", - "background-weak": "#f3f3f3", - "background-strong": "#fcfcfc", - "background-stronger": "#fcfcfc", - "surface-base": "#00000008", - "base": "#00000008", - "surface-base-hover": "#0000000f", - "surface-base-active": "#0000000d", - "surface-base-interactive-active": "var(--cobalt-light-alpha-3)", - "base2": "#00000008", - "base3": "#00000008", - "surface-inset-base": "#00000008", - "surface-inset-base-hover": "#0000000d", - "surface-inset-strong": "#00000017", - "surface-inset-strong-hover": "#00000017", - "surface-raised-base": "#00000008", - "surface-float-base": "#161616", - "surface-float-base-hover": "#1c1c1c", - "surface-raised-base-hover": "#0000000d", - "surface-raised-base-active": "#00000017", - "surface-raised-strong": "#fcfcfc", - "surface-raised-strong-hover": "var(--white)", - "surface-raised-stronger": "var(--white)", - "surface-raised-stronger-hover": "var(--white)", - "surface-weak": "#0000000d", - "surface-weaker": "#00000012", - "surface-strong": "#ffffff", - "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-diff-unchanged-base": "#ffffff00", - "surface-diff-skip-base": "#f8f8f8", - "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": "#fcfcfc", - "input-hover": "#f8f8f8", - "input-active": "var(--cobalt-light-1)", - "input-selected": "var(--cobalt-light-4)", - "input-focus": "var(--cobalt-light-1)", - "input-disabled": "#ededed", - "text-base": "#6f6f6f", - "text-weak": "#8f8f8f", - "text-weaker": "#c7c7c7", - "text-strong": "#171717", - "text-invert-base": "#ffffff96", - "text-invert-weak": "#ffffff63", - "text-invert-weaker": "#ffffff40", - "text-invert-strong": "#ffffffeb", - "text-interactive-base": "var(--cobalt-light-9)", - "text-on-brand-base": "#0000008f", - "text-on-interactive-base": "#fcfcfc", - "text-on-interactive-weak": "#ffffff96", - "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": "#ffffff96", - "text-on-info-base": "#ffffff96", - "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": "#ffffff63", - "text-on-info-strong": "#ffffffeb", - "text-on-warning-weak": "#ffffff63", - "text-on-warning-strong": "#ffffffeb", - "text-on-success-weak": "var(--apple-light-6)", - "text-on-success-strong": "var(--apple-light-12)", - "text-on-brand-weak": "#00000070", - "text-on-brand-weaker": "#00000038", - "text-on-brand-strong": "#000000e8", - "button-primary-base": "#171717", - "button-secondary-base": "#fcfcfc", - "button-secondary-hover": "FFFFFF0A", - "border-base": "#00000024", - "border-hover": "#00000038", - "border-active": "#00000070", - "border-selected": "var(--cobalt-light-alpha-9)", - "border-disabled": "#00000038", - "border-focus": "#00000070", - "border-weak-base": "#e5e5e5", - "border-strong-base": "#00000024", - "border-strong-hover": "#00000038", - "border-strong-active": "#00000024", - "border-strong-selected": "var(--cobalt-light-alpha-6)", - "border-strong-disabled": "#0000001c", - "border-strong-focus": "#00000024", - "border-weak-hover": "#0000001c", - "border-weak-active": "#00000024", - "border-weak-selected": "var(--cobalt-light-alpha-5)", - "border-weak-disabled": "#0000001c", - "border-weak-focus": "#00000024", - "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": "#c7c7c7", - "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": "#8f8f8f", - "icon-hover": "#6f6f6f", - "icon-active": "#171717", - "icon-selected": "#171717", - "icon-disabled": "#c7c7c7", - "icon-focus": "#171717", - "icon-invert-base": "#ffffff", - "icon-weak-base": "#dbdbdb", - "icon-weak-hover": "#c7c7c7", - "icon-weak-active": "#8f8f8f", - "icon-weak-selected": "#858585", - "icon-weak-disabled": "#e2e2e2", - "icon-weak-focus": "#8f8f8f", - "icon-strong-base": "#171717", - "icon-strong-hover": "#151515", - "icon-strong-active": "#020202", - "icon-strong-selected": "#020202", - "icon-strong-disabled": "#e2e2e2", - "icon-strong-focus": "#020202", - "icon-brand-base": "#171717", - "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-7)", - "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": "#0000008f", - "icon-on-brand-hover": "#000000e8", - "icon-on-brand-selected": "#000000e8", - "icon-on-interactive-base": "#fcfcfc", - "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)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#006656", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#fb4804", - "syntax-operator": "var(--text-base)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ed6dc8", - "syntax-type": "#596600", - "syntax-constant": "#007b80", - "syntax-punctuation": "var(--text-base)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-light-10)", - "syntax-warning": "var(--amber-light-10)", - "syntax-critical": "var(--ember-light-10)", - "syntax-info": "#0092a8", - "syntax-diff-add": "var(--mint-light-11)", - "syntax-diff-delete": "var(--ember-light-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#d68c27", - "markdown-text": "#1a1a1a", - "markdown-link": "#3b7dd8", - "markdown-link-text": "#318795", - "markdown-code": "#3d9a57", - "markdown-block-quote": "#b0851f", - "markdown-emph": "#b0851f", - "markdown-strong": "#d68c27", - "markdown-horizontal-rule": "#8a8a8a", - "markdown-list-item": "#3b7dd8", - "markdown-list-enumeration": "#318795", - "markdown-image": "#3b7dd8", - "markdown-image-text": "#318795", - "markdown-code-block": "#1a1a1a", - "border-color": "#ffffff", - "border-weaker-base": "#efefef", - "button-ghost-hover": "#00000008", - "button-ghost-hover2": "#0000000d", - "avatar-background-pink": "#feeef8", - "avatar-background-mint": "#e1fbf4", - "avatar-background-orange": "#fff1e7", - "avatar-background-purple": "#f9f1fe", - "avatar-background-cyan": "#e7f9fb", - "avatar-background-lime": "#eefadc", - "avatar-text-pink": "#cd1d8d", - "avatar-text-mint": "#147d6f", - "avatar-text-orange": "#ed5f00", - "avatar-text-purple": "#8445bc", - "avatar-text-cyan": "#0894b3", - "avatar-text-lime": "#5d770d" } }, "dark": { - "seeds": { + "palette": { "neutral": "#707070", "primary": "#fab283", "success": "#12c905", @@ -274,249 +26,6 @@ "interactive": "#034cff", "diffAdd": "#c8ffc4", "diffDelete": "#fc533a" - }, - "overrides": { - "base": "#ffffff08", - "base2": "#ffffff08", - "base3": "#ffffff08", - "background-base": "#101010", - "background-weak": "#1E1E1E", - "background-strong": "#121212", - "background-stronger": "#151515", - "surface-base": "#ffffff08", - "surface-base-hover": "#FFFFFF0A", - "surface-base-active": "#ffffff0f", - "surface-base-interactive-active": "var(--cobalt-dark-alpha-2)", - "surface-inset-base": "#0000007f", - "surface-inset-base-hover": "#0000007f", - "surface-inset-strong": "#000000cc", - "surface-inset-strong-hover": "#000000cc", - "surface-raised-base": "#ffffff0f", - "surface-float-base": "#161616", - "surface-float-base-hover": "#1c1c1c", - "surface-raised-base-hover": "#ffffff14", - "surface-raised-base-active": "#ffffff1a", - "surface-raised-strong": "#ffffff14", - "surface-raised-strong-hover": "#ffffff21", - "surface-raised-stronger": "#ffffff21", - "surface-raised-stronger-hover": "#ffffff2b", - "surface-weak": "#ffffff14", - "surface-weaker": "#ffffff1a", - "surface-strong": "#ffffff2b", - "surface-raised-stronger-non-alpha": "#1B1B1B", - "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-dark-3)", - "surface-success-weak": "var(--apple-dark-2)", - "surface-success-strong": "var(--apple-dark-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": "#161616", - "surface-diff-skip-base": "#00000000", - "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": "#1c1c1c", - "input-hover": "#1c1c1c", - "input-active": "var(--cobalt-dark-1)", - "input-selected": "var(--cobalt-dark-2)", - "input-focus": "var(--cobalt-dark-1)", - "input-disabled": "#282828", - "text-base": "#ffffff96", - "text-weak": "#ffffff63", - "text-weaker": "#ffffff40", - "text-strong": "#ffffffeb", - "text-invert-base": "#ffffff96", - "text-invert-weak": "#ffffff63", - "text-invert-weaker": "#ffffff40", - "text-invert-strong": "#ffffffeb", - "text-interactive-base": "var(--cobalt-dark-11)", - "text-on-brand-base": "#ffffff96", - "text-on-interactive-base": "#ededed", - "text-on-interactive-weak": "#ffffff96", - "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": "#ffffff96", - "text-on-info-base": "#ffffff96", - "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": "#ffffff63", - "text-on-info-strong": "#ffffffeb", - "text-on-warning-weak": "#ffffff63", - "text-on-warning-strong": "#ffffffeb", - "text-on-success-weak": "var(--apple-dark-8)", - "text-on-success-strong": "var(--apple-dark-12)", - "text-on-brand-weak": "#ffffff63", - "text-on-brand-weaker": "#ffffff40", - "text-on-brand-strong": "#ffffffeb", - "button-primary-base": "#ededed", - "button-secondary-base": "#1c1c1c", - "button-secondary-hover": "#FFFFFF0A", - "border-base": "#ffffff2b", - "border-hover": "#ffffff40", - "border-active": "#ffffff63", - "border-selected": "var(--cobalt-dark-alpha-11)", - "border-disabled": "#ffffff40", - "border-focus": "#ffffff63", - "border-weak-base": "#282828", - "border-weak-hover": "#ffffff2b", - "border-weak-active": "#ffffff40", - "border-weak-selected": "var(--cobalt-dark-alpha-6)", - "border-weak-disabled": "#ffffff21", - "border-weak-focus": "#ffffff40", - "border-strong-base": "#ffffff40", - "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": "#c7c7c7", - "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": "#7e7e7e", - "icon-hover": "#a0a0a0", - "icon-active": "#ededed", - "icon-selected": "#ededed", - "icon-disabled": "#505050", - "icon-focus": "#ededed", - "icon-invert-base": "#161616", - "icon-weak-base": "#343434", - "icon-weak-hover": "#dbdbdb", - "icon-weak-active": "#c7c7c7", - "icon-weak-selected": "#8f8f8f", - "icon-weak-disabled": "#ededed", - "icon-weak-focus": "#8f8f8f", - "icon-strong-base": "#ededed", - "icon-strong-hover": "#F3F3F3", - "icon-strong-active": "#EBEBEB", - "icon-strong-selected": "#FCFCFC", - "icon-strong-disabled": "#3e3e3e", - "icon-strong-focus": "#FCFCFC", - "icon-brand-base": "var(--white)", - "icon-interactive-base": "var(--cobalt-dark-11)", - "icon-success-base": "var(--apple-dark-9)", - "icon-success-hover": "var(--apple-dark-10)", - "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": "#0000008f", - "icon-on-brand-hover": "#000000e8", - "icon-on-brand-selected": "#000000e8", - "icon-on-interactive-base": "#ededed", - "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)", - "syntax-comment": "var(--text-weak)", - "syntax-regexp": "var(--text-base)", - "syntax-string": "#00ceb9", - "syntax-keyword": "var(--text-weak)", - "syntax-primitive": "#ffba92", - "syntax-operator": "var(--text-weak)", - "syntax-variable": "var(--text-strong)", - "syntax-property": "#ff9ae2", - "syntax-type": "#ecf58c", - "syntax-constant": "#93e9f6", - "syntax-punctuation": "var(--text-weak)", - "syntax-object": "var(--text-strong)", - "syntax-success": "var(--apple-dark-10)", - "syntax-warning": "var(--amber-dark-10)", - "syntax-critical": "var(--ember-dark-10)", - "syntax-info": "#93e9f6", - "syntax-diff-add": "var(--mint-dark-11)", - "syntax-diff-delete": "var(--ember-dark-11)", - "syntax-diff-unknown": "#ff0000", - "markdown-heading": "#9d7cd8", - "markdown-text": "#eeeeee", - "markdown-link": "#fab283", - "markdown-link-text": "#56b6c2", - "markdown-code": "#7fd88f", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#f5a742", - "markdown-horizontal-rule": "#808080", - "markdown-list-item": "#fab283", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#fab283", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#eeeeee", - "border-color": "#ffffff", - "border-weaker-base": "#1e1e1e", - "button-ghost-hover": "#ffffff08", - "button-ghost-hover2": "#ffffff0f", - "avatar-background-pink": "#501b3f", - "avatar-background-mint": "#033a34", - "avatar-background-orange": "#5f2a06", - "avatar-background-purple": "#432155", - "avatar-background-cyan": "#0f3058", - "avatar-background-lime": "#2b3711", - "avatar-text-pink": "#e34ba9", - "avatar-text-mint": "#95f3d9", - "avatar-text-orange": "#ff802b", - "avatar-text-purple": "#9d5bd2", - "avatar-text-cyan": "#369eff", - "avatar-text-lime": "#c4f042" } } } diff --git a/packages/ui/src/theme/themes/onedarkpro.json b/packages/ui/src/theme/themes/onedarkpro.json index ce01511e85c..be17dedff3d 100644 --- a/packages/ui/src/theme/themes/onedarkpro.json +++ b/packages/ui/src/theme/themes/onedarkpro.json @@ -3,129 +3,39 @@ "name": "One Dark Pro", "id": "onedarkpro", "light": { - "seeds": { + "palette": { "neutral": "#f5f6f8", + "ink": "#2b303b", "primary": "#528bff", + "accent": "#d85462", "success": "#4fa66d", "warning": "#d19a66", "error": "#e06c75", "info": "#61afef", - "interactive": "#528bff", "diffAdd": "#c2ebcf", "diffDelete": "#f7c1c5" }, "overrides": { - "background-base": "#f5f6f8", - "background-weak": "#eef0f4", - "background-strong": "#fafbfc", - "background-stronger": "#ffffff", - "border-weak-base": "#dee2eb", - "border-weak-hover": "#d4d9e3", - "border-weak-active": "#caced6", - "border-weak-selected": "#bec4d0", - "border-weak-disabled": "#f4f6fb", - "border-weak-focus": "#c4cada", - "border-base": "#b5bccd", - "border-hover": "#aab1c2", - "border-active": "#a0a7b8", - "border-selected": "#959cae", - "border-disabled": "#eceef4", - "border-focus": "#a6adbf", - "border-strong-base": "#747c92", - "border-strong-hover": "#6a7287", - "border-strong-active": "#60687c", - "border-strong-selected": "#565e71", - "border-strong-disabled": "#cbd0dd", - "border-strong-focus": "#666d82", - "surface-diff-add-base": "#e5f4ea", - "surface-diff-delete-base": "#fde7ea", - "surface-diff-hidden-base": "#e4e8f4", - "text-base": "#2b303b", - "text-weak": "#6b717f", - "text-strong": "#0e1118", - "syntax-string": "#4fa66d", - "syntax-primitive": "#d85462", - "syntax-property": "#528bff", - "syntax-type": "#d19a66", - "syntax-constant": "#61afef", - "syntax-info": "#61afef", - "markdown-heading": "#528bff", - "markdown-text": "#2b303b", - "markdown-link": "#528bff", - "markdown-link-text": "#61afef", - "markdown-code": "#4fa66d", - "markdown-block-quote": "#d19a66", - "markdown-emph": "#d19a66", - "markdown-strong": "#d85462", - "markdown-horizontal-rule": "#d3d7e4", - "markdown-list-item": "#528bff", - "markdown-list-enumeration": "#61afef", - "markdown-image": "#528bff", - "markdown-image-text": "#61afef", - "markdown-code-block": "#528bff" + "syntax-keyword": "#a626a4", + "syntax-primitive": "#986801" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1e222a", + "ink": "#abb2bf", "primary": "#61afef", + "accent": "#e06c75", "success": "#98c379", "warning": "#e5c07b", "error": "#e06c75", "info": "#56b6c2", - "interactive": "#61afef", "diffAdd": "#4b815a", "diffDelete": "#b2555f" }, "overrides": { - "background-base": "#1e222a", - "background-weak": "#212631", - "background-strong": "#1b1f27", - "background-stronger": "#171b23", - "border-weak-base": "#323848", - "border-weak-hover": "#363d52", - "border-weak-active": "#3c435c", - "border-weak-selected": "#424967", - "border-weak-disabled": "#141720", - "border-weak-focus": "#3f4560", - "border-base": "#4a5164", - "border-hover": "#515871", - "border-active": "#585f7e", - "border-selected": "#60688a", - "border-disabled": "#1a1e27", - "border-focus": "#555c79", - "border-strong-base": "#6a7390", - "border-strong-hover": "#737c9d", - "border-strong-active": "#7d87ab", - "border-strong-selected": "#8791b8", - "border-strong-disabled": "#212533", - "border-strong-focus": "#7680a2", - "surface-diff-add-base": "#1c2a26", - "surface-diff-delete-base": "#2a1c22", - "surface-diff-hidden-base": "#232836", - "text-base": "#abb2bf", - "text-weak": "#818899", - "text-strong": "#f6f7fb", - "syntax-string": "#98c379", - "syntax-primitive": "#e06c75", - "syntax-property": "#61afef", - "syntax-type": "#e5c07b", - "syntax-constant": "#56b6c2", - "syntax-info": "#56b6c2", - "markdown-heading": "#61afef", - "markdown-text": "#abb2bf", - "markdown-link": "#61afef", - "markdown-link-text": "#56b6c2", - "markdown-code": "#98c379", - "markdown-block-quote": "#e5c07b", - "markdown-emph": "#e5c07b", - "markdown-strong": "#e06c75", - "markdown-horizontal-rule": "#2d3444", - "markdown-list-item": "#61afef", - "markdown-list-enumeration": "#56b6c2", - "markdown-image": "#61afef", - "markdown-image-text": "#56b6c2", - "markdown-code-block": "#abb2bf" + "syntax-keyword": "#c678dd", + "syntax-primitive": "#d19a66" } } } diff --git a/packages/ui/src/theme/themes/shadesofpurple.json b/packages/ui/src/theme/themes/shadesofpurple.json index bc625770f9d..03af35c2a3d 100644 --- a/packages/ui/src/theme/themes/shadesofpurple.json +++ b/packages/ui/src/theme/themes/shadesofpurple.json @@ -3,129 +3,37 @@ "name": "Shades of Purple", "id": "shadesofpurple", "light": { - "seeds": { + "palette": { "neutral": "#f7ebff", + "ink": "#3b2c59", "primary": "#7a5af8", + "accent": "#ff6bd5", "success": "#3dd598", "warning": "#f7c948", "error": "#ff6bd5", "info": "#62d4ff", - "interactive": "#7a5af8", "diffAdd": "#c8f8da", "diffDelete": "#ffc3ef" }, "overrides": { - "background-base": "#f7ebff", - "background-weak": "#f2e2ff", - "background-strong": "#fbf2ff", - "background-stronger": "#fff7ff", - "border-weak-base": "#e5d3ff", - "border-weak-hover": "#dac8f5", - "border-weak-active": "#d1bdeb", - "border-weak-selected": "#c6b3e1", - "border-weak-disabled": "#fcf6ff", - "border-weak-focus": "#ccb9e7", - "border-base": "#baa4d5", - "border-hover": "#b098cb", - "border-active": "#a68dc2", - "border-selected": "#9b82b8", - "border-disabled": "#f1e7ff", - "border-focus": "#a692c6", - "border-strong-base": "#8769a9", - "border-strong-hover": "#7b5c9d", - "border-strong-active": "#704f91", - "border-strong-selected": "#664587", - "border-strong-disabled": "#d8c4f0", - "border-strong-focus": "#755495", - "surface-diff-add-base": "#edf8f1", - "surface-diff-delete-base": "#ffe4f4", - "surface-diff-hidden-base": "#e9e4ff", - "text-base": "#3b2c59", - "text-weak": "#6c568f", - "text-strong": "#1c1033", - "syntax-string": "#3dd598", - "syntax-primitive": "#ff6bd5", - "syntax-property": "#7a5af8", - "syntax-type": "#f7c948", - "syntax-constant": "#62d4ff", - "syntax-info": "#62d4ff", - "markdown-heading": "#7a5af8", - "markdown-text": "#3b2c59", - "markdown-link": "#7a5af8", - "markdown-link-text": "#62d4ff", - "markdown-code": "#3dd598", - "markdown-block-quote": "#f7c948", - "markdown-emph": "#f7c948", - "markdown-strong": "#ff6bd5", - "markdown-horizontal-rule": "#decbed", - "markdown-list-item": "#7a5af8", - "markdown-list-enumeration": "#62d4ff", - "markdown-image": "#7a5af8", - "markdown-image-text": "#62d4ff", - "markdown-code-block": "#7a5af8" + "syntax-keyword": "#ff6bd5" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1a102b", + "ink": "#f5f0ff", "primary": "#c792ff", + "accent": "#ff7ac6", "success": "#7be0b0", "warning": "#ffd580", "error": "#ff7ac6", "info": "#7dd4ff", - "interactive": "#c792ff", "diffAdd": "#53c39f", "diffDelete": "#d85aa0" }, "overrides": { - "background-base": "#1a102b", - "background-weak": "#1f1434", - "background-strong": "#1c122f", - "background-stronger": "#170e26", - "border-weak-base": "#352552", - "border-weak-hover": "#3a2a5d", - "border-weak-active": "#402f68", - "border-weak-selected": "#463674", - "border-weak-disabled": "#10091b", - "border-weak-focus": "#3d2d65", - "border-base": "#4d3a73", - "border-hover": "#553f7f", - "border-active": "#5d468c", - "border-selected": "#654c99", - "border-disabled": "#150d21", - "border-focus": "#594283", - "border-strong-base": "#7659b0", - "border-strong-hover": "#8262be", - "border-strong-active": "#8e6ccc", - "border-strong-selected": "#9a77da", - "border-strong-disabled": "#1c122c", - "border-strong-focus": "#8666c4", - "surface-diff-add-base": "#142c27", - "surface-diff-delete-base": "#2d1424", - "surface-diff-hidden-base": "#231737", - "text-base": "#f5f0ff", - "text-weak": "#c9b6ff", - "text-strong": "#ffffff", - "syntax-string": "#7be0b0", - "syntax-primitive": "#ff7ac6", - "syntax-property": "#c792ff", - "syntax-type": "#ffd580", - "syntax-constant": "#7dd4ff", - "syntax-info": "#7dd4ff", - "markdown-heading": "#c792ff", - "markdown-text": "#f5f0ff", - "markdown-link": "#c792ff", - "markdown-link-text": "#7dd4ff", - "markdown-code": "#7be0b0", - "markdown-block-quote": "#ffd580", - "markdown-emph": "#ffd580", - "markdown-strong": "#ff7ac6", - "markdown-horizontal-rule": "#2d1d41", - "markdown-list-item": "#c792ff", - "markdown-list-enumeration": "#7dd4ff", - "markdown-image": "#c792ff", - "markdown-image-text": "#7dd4ff", - "markdown-code-block": "#f5f0ff" + "syntax-keyword": "#ff7ac6" } } } diff --git a/packages/ui/src/theme/themes/solarized.json b/packages/ui/src/theme/themes/solarized.json index 7cb44775af6..24a4daf4583 100644 --- a/packages/ui/src/theme/themes/solarized.json +++ b/packages/ui/src/theme/themes/solarized.json @@ -3,129 +3,39 @@ "name": "Solarized", "id": "solarized", "light": { - "seeds": { + "palette": { "neutral": "#fdf6e3", + "ink": "#586e75", "primary": "#268bd2", + "accent": "#d33682", "success": "#859900", "warning": "#b58900", "error": "#dc322f", "info": "#2aa198", - "interactive": "#268bd2", "diffAdd": "#c6dc7a", "diffDelete": "#f2a1a1" }, "overrides": { - "background-base": "#fdf6e3", - "background-weak": "#f6efda", - "background-strong": "#faf3dc", - "background-stronger": "#f6edd4", - "border-weak-base": "#e3e0cd", - "border-weak-hover": "#d9d4c2", - "border-weak-active": "#cfcab7", - "border-weak-selected": "#c5c0ad", - "border-weak-disabled": "#f2edda", - "border-weak-focus": "#cbc6b2", - "border-base": "#bcb5a0", - "border-hover": "#b1aa96", - "border-active": "#a59f8c", - "border-selected": "#999382", - "border-disabled": "#ede7d4", - "border-focus": "#aca58f", - "border-strong-base": "#8c8572", - "border-strong-hover": "#7f7866", - "border-strong-active": "#716b5b", - "border-strong-selected": "#645f50", - "border-strong-disabled": "#d5cdb8", - "border-strong-focus": "#78715f", - "surface-diff-add-base": "#eef5d6", - "surface-diff-delete-base": "#fde4dd", - "surface-diff-hidden-base": "#e3ecf3", - "text-base": "#586e75", - "text-weak": "#7a8c8e", - "text-strong": "#073642", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#268bd2", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#268bd2", - "markdown-text": "#586e75", - "markdown-link": "#268bd2", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#cfd1bf", - "markdown-list-item": "#268bd2", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#268bd2", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#2aa198" + "syntax-keyword": "#859900", + "syntax-string": "#2aa198" } }, "dark": { - "seeds": { + "palette": { "neutral": "#002b36", + "ink": "#93a1a1", "primary": "#6c71c4", + "accent": "#d33682", "success": "#859900", "warning": "#b58900", "error": "#dc322f", "info": "#2aa198", - "interactive": "#6c71c4", "diffAdd": "#4c7654", "diffDelete": "#c34b4b" }, "overrides": { - "background-base": "#001f27", - "background-weak": "#022733", - "background-strong": "#01222b", - "background-stronger": "#032830", - "border-weak-base": "#20373f", - "border-weak-hover": "#243e47", - "border-weak-active": "#28434f", - "border-weak-selected": "#2d4958", - "border-weak-disabled": "#0f2026", - "border-weak-focus": "#2a4552", - "border-base": "#31505b", - "border-hover": "#365765", - "border-active": "#3c5e70", - "border-selected": "#42657a", - "border-disabled": "#13272e", - "border-focus": "#3a5a6b", - "border-strong-base": "#4a7887", - "border-strong-hover": "#528294", - "border-strong-active": "#5a8ca1", - "border-strong-selected": "#6396ae", - "border-strong-disabled": "#1b323b", - "border-strong-focus": "#56879a", - "surface-diff-add-base": "#0f2f29", - "surface-diff-delete-base": "#321c1c", - "surface-diff-hidden-base": "#0f3844", - "text-base": "#93a1a1", - "text-weak": "#6c7f80", - "text-strong": "#fdf6e3", - "syntax-string": "#859900", - "syntax-primitive": "#d33682", - "syntax-property": "#6c71c4", - "syntax-type": "#b58900", - "syntax-constant": "#2aa198", - "syntax-info": "#2aa198", - "markdown-heading": "#6c71c4", - "markdown-text": "#93a1a1", - "markdown-link": "#6c71c4", - "markdown-link-text": "#2aa198", - "markdown-code": "#859900", - "markdown-block-quote": "#b58900", - "markdown-emph": "#b58900", - "markdown-strong": "#d33682", - "markdown-horizontal-rule": "#0e3b46", - "markdown-list-item": "#6c71c4", - "markdown-list-enumeration": "#2aa198", - "markdown-image": "#6c71c4", - "markdown-image-text": "#2aa198", - "markdown-code-block": "#93a1a1" + "syntax-keyword": "#859900", + "syntax-string": "#2aa198" } } } diff --git a/packages/ui/src/theme/themes/tokyonight.json b/packages/ui/src/theme/themes/tokyonight.json index 31d0e8a4743..d29c3599425 100644 --- a/packages/ui/src/theme/themes/tokyonight.json +++ b/packages/ui/src/theme/themes/tokyonight.json @@ -3,153 +3,37 @@ "name": "Tokyonight", "id": "tokyonight", "light": { - "seeds": { + "palette": { "neutral": "#e1e2e7", + "ink": "#273153", "primary": "#2e7de9", + "accent": "#b15c00", "success": "#587539", "warning": "#8c6c3e", "error": "#c94060", "info": "#007197", - "interactive": "#2e7de9", "diffAdd": "#4f8f7b", "diffDelete": "#d05f7c" }, "overrides": { - "background-base": "#e1e2e7", - "background-weak": "#dee0ea", - "background-strong": "#e5e6ee", - "background-stronger": "#e9eaf1", - "border-weak-base": "#cdd0dc", - "border-weak-hover": "#c3c6d2", - "border-weak-active": "#b9bcc8", - "border-weak-selected": "#aeb2bf", - "border-weak-disabled": "#e6e7ef", - "border-weak-focus": "#b3b6c3", - "border-base": "#a7abbb", - "border-hover": "#9ba0b1", - "border-active": "#9095a8", - "border-selected": "#83889e", - "border-disabled": "#dedfe6", - "border-focus": "#9599a8", - "border-strong-base": "#757b90", - "border-strong-hover": "#6a7084", - "border-strong-active": "#5f6578", - "border-strong-selected": "#545a6d", - "border-strong-disabled": "#c4c6d0", - "border-strong-focus": "#666b7f", - "surface-diff-add-base": "#dfe7da", - "surface-diff-delete-base": "#f4dadd", - "surface-diff-hidden-base": "#cfd1dd", - "text-base": "#273153", - "text-weak": "#5c6390", - "text-strong": "#1c2544", - "syntax-string": "#587539", - "syntax-primitive": "#b15c00", - "syntax-property": "#9854f1", - "syntax-type": "#3760bf", - "syntax-constant": "#007197", - "syntax-info": "#007197", - "markdown-heading": "#9854f1", - "markdown-text": "#273153", - "markdown-link": "#2e7de9", - "markdown-link-text": "#007197", - "markdown-code": "#587539", - "markdown-block-quote": "#8c6c3e", - "markdown-emph": "#8c6c3e", - "markdown-strong": "#b15c00", - "markdown-horizontal-rule": "#a1a6c5", - "markdown-list-item": "#2e7de9", - "markdown-list-enumeration": "#007197", - "markdown-image": "#2e7de9", - "markdown-image-text": "#007197", - "markdown-code-block": "#3760bf" + "syntax-keyword": "#9854f1" } }, "dark": { - "seeds": { + "palette": { "neutral": "#1a1b26", + "ink": "#c0caf5", "primary": "#7aa2f7", + "accent": "#ff9e64", "success": "#9ece6a", "warning": "#e0af68", "error": "#f7768e", "info": "#7dcfff", - "interactive": "#7aa2f7", "diffAdd": "#41a6b5", "diffDelete": "#c34043" }, "overrides": { - "background-base": "#0f111a", - "background-weak": "#111428", - "background-strong": "#101324", - "background-stronger": "#13172a", - "border-weak-base": "#25283b", - "border-weak-hover": "#292c43", - "border-weak-active": "#2e314b", - "border-weak-selected": "#343755", - "border-weak-disabled": "#151727", - "border-weak-focus": "#30324f", - "border-base": "#3a3e57", - "border-hover": "#414264", - "border-active": "#474972", - "border-selected": "#4f507f", - "border-disabled": "#1c1d2d", - "border-focus": "#45496f", - "border-strong-base": "#5a5f82", - "border-strong-hover": "#646994", - "border-strong-active": "#6f74a6", - "border-strong-selected": "#7a7fb8", - "border-strong-disabled": "#23243a", - "border-strong-focus": "#6a6f9f", - "surface-base": "#1f2335", - "base": "#1f2335", - "surface-base-hover": "#232840", - "surface-base-active": "#262c46", - "surface-base-interactive-active": "#2b3357", - "base2": "#1f2335", - "base3": "#1f2335", - "surface-inset-base": "#161a2ab3", - "surface-inset-base-hover": "#161a2acc", - "surface-inset-strong": "#0d111fcc", - "surface-inset-strong-hover": "#0d111fcc", - "surface-raised-base": "#242a42", - "surface-float-base": "#242b45", - "surface-float-base-hover": "#2a3154", - "surface-raised-base-hover": "#272e49", - "surface-raised-base-active": "#2c3353", - "surface-raised-strong": "#31385a", - "surface-raised-strong-hover": "#373f6b", - "surface-raised-stronger": "#3b4261", - "surface-raised-stronger-hover": "#444c82", - "surface-weak": "#1b2033", - "surface-weaker": "#181d2d", - "surface-strong": "#323858", - "surface-raised-stronger-non-alpha": "#2b3150", - "surface-diff-add-base": "#1c2a38", - "surface-diff-delete-base": "#2a1f32", - "surface-diff-hidden-base": "#24283b", - "text-base": "#c0caf5", - "text-weak": "#7a88cf", - "text-strong": "#eaeaff", - "syntax-string": "#9ece6a", - "syntax-primitive": "#ff9e64", - "syntax-property": "#bb9af7", - "syntax-type": "#e0af68", - "syntax-constant": "#7dcfff", - "syntax-info": "#7dcfff", - "markdown-heading": "#bb9af7", - "markdown-text": "#c0caf5", - "markdown-link": "#7aa2f7", - "markdown-link-text": "#7dcfff", - "markdown-code": "#9ece6a", - "markdown-block-quote": "#e0af68", - "markdown-emph": "#e0af68", - "markdown-strong": "#ff9e64", - "markdown-horizontal-rule": "#3b4261", - "markdown-list-item": "#7aa2f7", - "markdown-list-enumeration": "#7dcfff", - "markdown-image": "#7aa2f7", - "markdown-image-text": "#7dcfff", - "markdown-code-block": "#c0caf5" + "syntax-keyword": "#bb9af7" } } } diff --git a/packages/ui/src/theme/themes/vesper.json b/packages/ui/src/theme/themes/vesper.json index 040bdc049ba..8cc658232f8 100644 --- a/packages/ui/src/theme/themes/vesper.json +++ b/packages/ui/src/theme/themes/vesper.json @@ -3,129 +3,38 @@ "name": "Vesper", "id": "vesper", "light": { - "seeds": { + "palette": { "neutral": "#F0F0F0", + "ink": "#101010", "primary": "#FFC799", + "accent": "#B30000", "success": "#99FFE4", "warning": "#FFC799", "error": "#FF8080", "info": "#FFC799", - "interactive": "#FFC799", "diffAdd": "#99FFE4", "diffDelete": "#FF8080" }, "overrides": { - "background-base": "#FFF", - "background-weak": "#F8F8F8", - "background-strong": "#F0F0F0", - "background-stronger": "#FBFBFB", - "border-weak-hover": "#E0E0E0", - "border-weak-active": "#D8D8D8", - "border-weak-selected": "#D0D0D0", - "border-weak-disabled": "#F0F0F0", - "border-weak-focus": "#D8D8D8", - "border-base": "#D0D0D0", - "border-hover": "#C8C8C8", - "border-active": "#C0C0C0", - "border-selected": "#B8B8B8", - "border-disabled": "#E8E8E8", - "border-focus": "#C0C0C0", - "border-strong-base": "#A0A0A0", - "border-strong-hover": "#989898", - "border-strong-active": "#909090", - "border-strong-selected": "#888888", - "border-strong-disabled": "#D0D0D0", - "border-strong-focus": "#909090", - "surface-diff-add-base": "#e8f5e8", - "surface-diff-delete-base": "#f5e8e8", - "surface-diff-hidden-base": "#F0F0F0", - "text-base": "#101010", - "text-invert-strong": "var(--smoke-dark-alpha-12)", - "text-weak": "#606060", - "text-strong": "#000000", - "syntax-string": "#0D5C4F", - "syntax-primitive": "#B30000", - "syntax-property": "#C66C00", - "syntax-type": "#9C5C12", - "syntax-constant": "#404040", - "syntax-info": "#606060", - "markdown-heading": "#FFC799", - "markdown-text": "#101010", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#101010", - "markdown-emph": "#101010", - "markdown-strong": "#101010", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#101010", - "markdown-list-enumeration": "#101010", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFC799" + "syntax-keyword": "#b30000" } }, "dark": { - "seeds": { + "palette": { "neutral": "#101010", + "ink": "#FFF", "primary": "#FFC799", + "accent": "#FF8080", "success": "#99FFE4", "warning": "#FFC799", "error": "#FF8080", "info": "#FFC799", - "interactive": "#FFC799", "diffAdd": "#99FFE4", "diffDelete": "#FF8080" }, "overrides": { - "background-base": "#101010", - "background-weak": "#141414", - "background-strong": "#0C0C0C", - "background-stronger": "#080808", - "border-weak-base": "#1C1C1C", - "border-weak-hover": "#202020", - "border-weak-active": "#242424", - "border-weak-selected": "#282828", - "border-weak-disabled": "#141414", - "border-weak-focus": "#242424", - "border-base": "#282828", - "border-hover": "#303030", - "border-active": "#383838", - "border-selected": "#404040", - "border-disabled": "#181818", - "border-focus": "#383838", - "border-strong-base": "#505050", - "border-strong-hover": "#585858", - "border-strong-active": "#606060", - "border-strong-selected": "#686868", - "border-strong-disabled": "#202020", - "border-strong-focus": "#606060", - "surface-diff-add-base": "#0d2818", - "surface-diff-delete-base": "#281a1a", - "surface-diff-hidden-base": "#141414", - "text-base": "#FFF", - "text-weak": "#A0A0A0", - "text-strong": "#FFFFFF", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#8b8b8b", - "markdown-heading": "#FFC799", - "markdown-text": "#FFF", - "markdown-link": "#FFC799", - "markdown-link-text": "#A0A0A0", - "markdown-code": "#A0A0A0", - "markdown-block-quote": "#FFF", - "markdown-emph": "#FFF", - "markdown-strong": "#FFF", - "markdown-horizontal-rule": "#65737E", - "markdown-list-item": "#FFF", - "markdown-list-enumeration": "#FFF", - "markdown-image": "#FFC799", - "markdown-image-text": "#A0A0A0", - "markdown-code-block": "#FFF" + "syntax-keyword": "#ff8080", + "syntax-primitive": "#ffc799" } } } diff --git a/packages/ui/src/theme/types.ts b/packages/ui/src/theme/types.ts index 73bd372b45b..126e9bb5aaa 100644 --- a/packages/ui/src/theme/types.ts +++ b/packages/ui/src/theme/types.ts @@ -18,11 +18,28 @@ export interface ThemeSeedColors { diffDelete: HexColor } -export interface ThemeVariant { - seeds: ThemeSeedColors +export interface ThemePaletteColors { + neutral: HexColor + ink?: HexColor + primary: HexColor + success: HexColor + warning: HexColor + error: HexColor + info: HexColor + accent?: HexColor + interactive?: HexColor + diffAdd?: HexColor + diffDelete?: HexColor +} + +type ThemeVariantBase = { overrides?: Record<string, ColorValue> } +export type ThemeVariant = + | ({ seeds: ThemeSeedColors; palette?: never } & ThemeVariantBase) + | ({ palette: ThemePaletteColors; seeds?: never } & ThemeVariantBase) + export interface DesktopTheme { $schema?: string name: string diff --git a/packages/util/package.json b/packages/util/package.json index 04b0bb93f49..1b36f37484b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.2.22", + "version": "1.2.24", "private": true, "type": "module", "license": "MIT", diff --git a/packages/util/src/array.ts b/packages/util/src/array.ts index 91b923dee2a..1fb8ac69ecd 100644 --- a/packages/util/src/array.ts +++ b/packages/util/src/array.ts @@ -1,10 +1,3 @@ -export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) { - if (a === b) return true - if (!a || !b) return false - if (a.length !== b.length) return false - return a.every((x, i) => x === b[i]) -} - export function findLast<T>( items: readonly T[], predicate: (item: T, index: number, items: readonly T[]) => boolean, diff --git a/packages/web/package.json b/packages/web/package.json index 783b3d1a6f1..378493853bb 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.2.22", + "version": "1.2.24", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/packages/web/src/content/docs/ar/keybinds.mdx b/packages/web/src/content/docs/ar/keybinds.mdx index f07eaed37b4..986313a5b53 100644 --- a/packages/web/src/content/docs/ar/keybinds.mdx +++ b/packages/web/src/content/docs/ar/keybinds.mdx @@ -28,6 +28,7 @@ description: خصّص اختصارات لوحة المفاتيح. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/bs/keybinds.mdx b/packages/web/src/content/docs/bs/keybinds.mdx index a7a6d34a0de..a70fdedaef2 100644 --- a/packages/web/src/content/docs/bs/keybinds.mdx +++ b/packages/web/src/content/docs/bs/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode ima listu veza tipki koje možete prilagoditi putem `tui.json`. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/da/keybinds.mdx b/packages/web/src/content/docs/da/keybinds.mdx index 9c066fcb49c..237c36f7754 100644 --- a/packages/web/src/content/docs/da/keybinds.mdx +++ b/packages/web/src/content/docs/da/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode har en liste over nøglebindinger, som du kan tilpasse gennem `tui.json "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/de/keybinds.mdx b/packages/web/src/content/docs/de/keybinds.mdx index d5758008057..628c65006d8 100644 --- a/packages/web/src/content/docs/de/keybinds.mdx +++ b/packages/web/src/content/docs/de/keybinds.mdx @@ -3,11 +3,11 @@ title: Tastenkombinationen description: Passen Sie Ihre Tastenkombinationen an. --- -OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über die OpenCode-Konfiguration anpassen können. +OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über `tui.json` anpassen können. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": { "leader": "ctrl+x", "app_exit": "ctrl+c,ctrl+d,<leader>q", @@ -28,6 +28,7 @@ OpenCode verfügt über eine Liste von Tastenkombinationen, die Sie über die Op "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", @@ -117,11 +118,11 @@ Sie müssen für Ihre Keybinds keinen Leader Key verwenden, wir empfehlen jedoch ## Keybind deaktivieren -Sie können eine Keybind deaktivieren, indem Sie den Schlüssel mit dem Wert „none“ zu Ihrer Konfiguration hinzufügen. +Sie können eine Keybind deaktivieren, indem Sie den Schlüssel mit dem Wert „none“ zu `tui.json` hinzufügen. -```json title="opencode.json" +```json title="tui.json" { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://opencode.ai/tui.json", "keybinds": { "session_compact": "none" } diff --git a/packages/web/src/content/docs/es/keybinds.mdx b/packages/web/src/content/docs/es/keybinds.mdx index d4880db2fa5..4c1f7e1a800 100644 --- a/packages/web/src/content/docs/es/keybinds.mdx +++ b/packages/web/src/content/docs/es/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode tiene una lista de combinaciones de teclas que puede personalizar a tra "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/fr/keybinds.mdx b/packages/web/src/content/docs/fr/keybinds.mdx index 4ec98adfa2b..281e5df7434 100644 --- a/packages/web/src/content/docs/fr/keybinds.mdx +++ b/packages/web/src/content/docs/fr/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode a une liste de raccourcis clavier que vous pouvez personnaliser via la "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/it/keybinds.mdx b/packages/web/src/content/docs/it/keybinds.mdx index 548805e6bae..e599f4e4172 100644 --- a/packages/web/src/content/docs/it/keybinds.mdx +++ b/packages/web/src/content/docs/it/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode ha una lista di scorciatoie che puoi personalizzare tramite `tui.json`. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/ja/keybinds.mdx b/packages/web/src/content/docs/ja/keybinds.mdx index 09a3b0d7cba..3ec9ca94d5d 100644 --- a/packages/web/src/content/docs/ja/keybinds.mdx +++ b/packages/web/src/content/docs/ja/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode には、`tui.json` を通じてカスタマイズできるキーバイ "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 95b3d496391..54c15e8621b 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/ko/keybinds.mdx b/packages/web/src/content/docs/ko/keybinds.mdx index 2920a223574..fa8b4e9bddc 100644 --- a/packages/web/src/content/docs/ko/keybinds.mdx +++ b/packages/web/src/content/docs/ko/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode에는 `tui.json`을 통해 커스터마이즈할 수 있는 키바인 "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/nb/keybinds.mdx b/packages/web/src/content/docs/nb/keybinds.mdx index 8314b4dd82b..f9837480dc3 100644 --- a/packages/web/src/content/docs/nb/keybinds.mdx +++ b/packages/web/src/content/docs/nb/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode har en liste over tastebindinger som du kan tilpasse gjennom `tui.json` "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/pl/keybinds.mdx b/packages/web/src/content/docs/pl/keybinds.mdx index 03b9ec9c2a2..4744ffc7836 100644 --- a/packages/web/src/content/docs/pl/keybinds.mdx +++ b/packages/web/src/content/docs/pl/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode zawiera listę skrótów klawiszowych, które można dostosować za pom "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/pt-br/keybinds.mdx b/packages/web/src/content/docs/pt-br/keybinds.mdx index 1829763ade1..6c7fcd208eb 100644 --- a/packages/web/src/content/docs/pt-br/keybinds.mdx +++ b/packages/web/src/content/docs/pt-br/keybinds.mdx @@ -28,6 +28,7 @@ O opencode tem uma lista de atalhos de teclado que você pode personalizar atrav "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/ru/keybinds.mdx b/packages/web/src/content/docs/ru/keybinds.mdx index 8a9a14ca1ae..bfd4bf0c242 100644 --- a/packages/web/src/content/docs/ru/keybinds.mdx +++ b/packages/web/src/content/docs/ru/keybinds.mdx @@ -28,6 +28,7 @@ opencode имеет список сочетаний клавиш, которые "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/th/keybinds.mdx b/packages/web/src/content/docs/th/keybinds.mdx index 8cc7586e5f1..ce3234a04ab 100644 --- a/packages/web/src/content/docs/th/keybinds.mdx +++ b/packages/web/src/content/docs/th/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode มีรายการปุ่มลัดที่คุณปร "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/tr/keybinds.mdx b/packages/web/src/content/docs/tr/keybinds.mdx index bea63a35503..7d3142bf38a 100644 --- a/packages/web/src/content/docs/tr/keybinds.mdx +++ b/packages/web/src/content/docs/tr/keybinds.mdx @@ -28,6 +28,7 @@ opencode, `tui.json` aracılığıyla özelleştirebileceğiniz bir tuş bağlan "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", @@ -134,21 +135,21 @@ Anahtarı yapılandırmanıza "none" değeriyle ekleyerek bir tuş atamasını d opencode masaüstü uygulaması bilgi istemi girişi, metni düzenlemek için yaygın Readline/Emacs tarzı kısayolları destekler. Bunlar yerleşiktir ve şu anda `opencode.json` aracılığıyla yapılandırılamaz. -| Shortcut | Action | -| -------- | ---------------------------------------- | -| `ctrl+a` | Geçerli satırın başına git | -| `ctrl+e` | Move to end of current line | -| `ctrl+b` | Move cursor back one character | -| `ctrl+f` | Move cursor forward one character | -| `alt+b` | Move cursor back one word | -| `alt+f` | Move cursor forward one word | -| `ctrl+d` | Delete character under cursor | -| `ctrl+k` | Kill to end of line | -| `ctrl+u` | Satırın başına kadar öldür | -| `ctrl+w` | Kill previous word | -| `alt+d` | Kill next word | -| `ctrl+t` | Transpose characters | -| `ctrl+g` | Cancel popovers / abort running response | +| Shortcut | Action | +| -------- | --------------------------------------------------- | +| `ctrl+a` | Geçerli satırın başına git | +| `ctrl+e` | Geçerli satırın sonuna git | +| `ctrl+b` | İmleci bir karakter geri taşı | +| `ctrl+f` | İmleci bir karakter ileri taşı | +| `alt+b` | İmleci bir kelime geri taşı | +| `alt+f` | İmleci bir kelime ileri taşı | +| `ctrl+d` | İmleç altındaki karakteri sil | +| `ctrl+k` | Satırın sonuna kadar sil | +| `ctrl+u` | Satırın başına kadar sil | +| `ctrl+w` | Önceki kelimeyi sil | +| `alt+d` | Sonraki kelimeyi sil | +| `ctrl+t` | Karakterlerin yerini değiştir | +| `ctrl+g` | Açılır pencereleri iptal et / çalışan yanıtı durdur | --- @@ -158,7 +159,7 @@ Bazı terminaller varsayılan olarak Enter ile değiştirici tuşlar göndermez. ### Windows Terminali -`settings.json` cihazınızı şu adreste açın: +`settings.json` dosyasını şurada açın: ``` %LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json diff --git a/packages/web/src/content/docs/zh-cn/keybinds.mdx b/packages/web/src/content/docs/zh-cn/keybinds.mdx index 5108fdbb519..33f75c6dc82 100644 --- a/packages/web/src/content/docs/zh-cn/keybinds.mdx +++ b/packages/web/src/content/docs/zh-cn/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode 提供了一系列快捷键,您可以通过 `tui.json` 进行自定 "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/packages/web/src/content/docs/zh-tw/keybinds.mdx b/packages/web/src/content/docs/zh-tw/keybinds.mdx index ca085db01d8..574404b2fdd 100644 --- a/packages/web/src/content/docs/zh-tw/keybinds.mdx +++ b/packages/web/src/content/docs/zh-tw/keybinds.mdx @@ -28,6 +28,7 @@ OpenCode 提供了一系列快捷鍵,您可以透過 `tui.json` 進行自訂 "session_unshare": "none", "session_interrupt": "escape", "session_compact": "<leader>c", + "session_child_first": "<leader>down", "session_child_cycle": "<leader>right", "session_child_cycle_reverse": "<leader>left", "session_parent": "<leader>up", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index dcbbbc3d07c..6eedb0d7517 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.2.22", + "version": "1.2.24", "publisher": "sst-dev", "repository": { "type": "git",