mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-10 00:24:03 +00:00
Compare commits
66 Commits
pr-16286
...
error-card
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77a24ad87 | ||
|
|
0941a40953 | ||
|
|
eaaf2c64db | ||
|
|
269b6da131 | ||
|
|
16f50213a7 | ||
|
|
bdc423d622 | ||
|
|
f20ee2fad2 | ||
|
|
8b9710e56c | ||
|
|
c6262f9d40 | ||
|
|
b749fa90f2 | ||
|
|
8a51cbd253 | ||
|
|
399b8f0701 | ||
|
|
3742e42fdf | ||
|
|
0388ec6862 | ||
|
|
366b8a8034 | ||
|
|
ef9bc4ec9e | ||
|
|
5838b58913 | ||
|
|
2712244ad3 | ||
|
|
6388cbaf92 | ||
|
|
5cc61e1b53 | ||
|
|
0243be86a7 | ||
|
|
9154cd64e7 | ||
|
|
c71d1bde5e | ||
|
|
f27ef595f6 | ||
|
|
34328828ae | ||
|
|
18fb19da3b | ||
|
|
849e1ac543 | ||
|
|
656a8d8f55 | ||
|
|
b976f339e8 | ||
|
|
7d7837e5b6 | ||
|
|
1db292f4df | ||
|
|
49a3a9fe36 | ||
|
|
e51ed460a6 | ||
|
|
d15c2ce349 | ||
|
|
5cc4bb4089 | ||
|
|
6e9e027886 | ||
|
|
f9a3d129a4 | ||
|
|
c53d1d3ad8 | ||
|
|
f386137fba | ||
|
|
c797b60069 | ||
|
|
a139e9297d | ||
|
|
050f99ec54 | ||
|
|
23ed652901 | ||
|
|
13a68f3de3 | ||
|
|
fdad35aaa7 | ||
|
|
a2ce4eb650 | ||
|
|
8fa04986cf | ||
|
|
a5710ed3e1 | ||
|
|
2efdc9df93 | ||
|
|
0c245886fe | ||
|
|
f03288b411 | ||
|
|
09388c98f3 | ||
|
|
ae25c1e7b7 | ||
|
|
0813c14cc6 | ||
|
|
b5151c421f | ||
|
|
e66fd079db | ||
|
|
207ebf4b8c | ||
|
|
12d862dbd3 | ||
|
|
981353793d | ||
|
|
426dcfa3b0 | ||
|
|
69cb49f7cc | ||
|
|
e30678a088 | ||
|
|
771b29a857 | ||
|
|
e6d1aae33a | ||
|
|
9dc8ac4734 | ||
|
|
fdd037ba20 |
38
.github/workflows/storybook.yml
vendored
Normal file
38
.github/workflows/storybook.yml
vendored
Normal file
@@ -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
|
||||
@@ -5,6 +5,11 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"permission": {
|
||||
"edit": {
|
||||
"packages/opencode/migration/*": "deny",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
|
||||
32
bun.lock
32
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"version": "1.2.24",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -409,7 +409,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"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.21",
|
||||
"version": "1.2.24",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -521,7 +521,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -532,7 +532,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||
import {
|
||||
dropdownMenuTriggerSelector,
|
||||
dropdownMenuContentSelector,
|
||||
sessionTimelineHeaderSelector,
|
||||
projectMenuTriggerSelector,
|
||||
projectCloseMenuSelector,
|
||||
projectWorkspacesToggleSelector,
|
||||
@@ -244,9 +243,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
|
||||
const scroller = page.locator(".scroll-view__viewport").first()
|
||||
await expect(scroller).toBeVisible()
|
||||
const header = page.locator(sessionTimelineHeaderSelector).first()
|
||||
await expect(header).toBeVisible({ timeout: 30_000 })
|
||||
await expect(header.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
const menu = page
|
||||
.locator(dropdownMenuContentSelector)
|
||||
@@ -262,7 +259,7 @@ export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||
|
||||
if (opened) return menu
|
||||
|
||||
const menuTrigger = header.getByRole("button", { name: /more options/i }).first()
|
||||
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||
await expect(menuTrigger).toBeVisible()
|
||||
await menuTrigger.click()
|
||||
|
||||
|
||||
@@ -25,16 +25,26 @@ test("closing active project navigates to another open project", async ({ page,
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
})
|
||||
.poll(
|
||||
() => {
|
||||
const pathname = new URL(page.url()).pathname
|
||||
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||
if (pathname === "/") return "home"
|
||||
return ""
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toMatch(/^(project|home)$/)
|
||||
|
||||
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
.toBe(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -44,12 +44,14 @@ async function store(page: Page, key: string) {
|
||||
}, key)
|
||||
}
|
||||
|
||||
test("terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||
test("inactive terminal tab buffers persist across tab switches", async ({ page, withProject }) => {
|
||||
await withProject(async ({ directory, gotoSession }) => {
|
||||
const key = workspacePersistKey(directory, "terminal")
|
||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
||||
const first = tabs.filter({ hasText: /Terminal 1/ }).first()
|
||||
const second = tabs.filter({ hasText: /Terminal 2/ }).first()
|
||||
|
||||
await gotoSession()
|
||||
await open(page)
|
||||
@@ -61,22 +63,39 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
|
||||
|
||||
await run(page, `echo ${two}`)
|
||||
|
||||
await tabs
|
||||
.filter({ hasText: /Terminal 1/ })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
await first.click()
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return first.includes(one) && second.includes(two)
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
.toEqual({ first: false, second: true })
|
||||
|
||||
await second.click()
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const state = await store(page, key)
|
||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||
const second = state?.all.find((item) => item.titleNumber === 2)?.buffer ?? ""
|
||||
return {
|
||||
first: first.includes(one),
|
||||
second: second.includes(two),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({ first: true, second: false })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function sessionPath(directory: string, sessionID?: string) {
|
||||
}
|
||||
|
||||
export function workspacePersistKey(directory: string, key: string) {
|
||||
const head = directory.slice(0, 12) || "workspace"
|
||||
const head = (directory.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(directory) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat:workspace:${key}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
|
||||
const MAIN_WORKTREE = "main"
|
||||
@@ -52,7 +53,10 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
<div class="h-12 shrink-0" aria-hidden />
|
||||
<div class="flex-1 px-6 pb-30 flex items-center justify-center text-center">
|
||||
<div class="w-full max-w-200 flex flex-col items-center text-center gap-4">
|
||||
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<Mark class="w-10" />
|
||||
<div class="text-20-medium text-text-strong">{language.t("session.new.title")}</div>
|
||||
</div>
|
||||
<div class="w-full flex flex-col gap-4 items-center">
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak select-text leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
@@ -189,6 +189,7 @@ function createGlobalSync() {
|
||||
})
|
||||
if (next.length !== store.session.length) {
|
||||
setStore("session", reconcile(next, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||
}
|
||||
children.unpin(directory)
|
||||
return
|
||||
@@ -220,6 +221,7 @@ function createGlobalSync() {
|
||||
}),
|
||||
)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
|
||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||
({
|
||||
@@ -248,6 +248,62 @@ describe("applyDirectoryEvent", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("cleans caches for trimmed sessions on session.created", () => {
|
||||
const dropped = rootSession({ id: "ses_b" })
|
||||
const kept = rootSession({ id: "ses_a" })
|
||||
const message = userMessage("msg_1", dropped.id)
|
||||
const todos: string[] = []
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
limit: 1,
|
||||
session: [dropped],
|
||||
message: { [dropped.id]: [message] },
|
||||
part: { [message.id]: [textPart("prt_1", dropped.id, message.id)] },
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
question: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "session.created", properties: { info: kept } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
setSessionTodo(sessionID, value) {
|
||||
if (value !== undefined) return
|
||||
todos.push(sessionID)
|
||||
},
|
||||
})
|
||||
|
||||
expect(store.session.map((x) => x.id)).toEqual([kept.id])
|
||||
expect(store.message[dropped.id]).toBeUndefined()
|
||||
expect(store.part[message.id]).toBeUndefined()
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.question[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
|
||||
test("cleanupDroppedSessionCaches clears part-only orphan state", () => {
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
session: [rootSession({ id: "ses_keep" })],
|
||||
part: { msg_1: [textPart("prt_1", "ses_drop", "msg_1")] },
|
||||
}),
|
||||
)
|
||||
|
||||
cleanupDroppedSessionCaches(store, setStore, store.session)
|
||||
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("upserts and removes messages while clearing orphaned parts", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
event: { type: string; properties?: unknown }
|
||||
@@ -40,37 +41,44 @@ export function applyGlobalEvent(input: {
|
||||
}
|
||||
|
||||
function cleanupSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
sessionID: string,
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
if (!sessionID) return
|
||||
const hasAny =
|
||||
store.message[sessionID] !== undefined ||
|
||||
store.session_diff[sessionID] !== undefined ||
|
||||
store.todo[sessionID] !== undefined ||
|
||||
store.permission[sessionID] !== undefined ||
|
||||
store.question[sessionID] !== undefined ||
|
||||
store.session_status[sessionID] !== undefined
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
if (!hasAny) return
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
const messages = draft.message[sessionID]
|
||||
if (messages) {
|
||||
for (const message of messages) {
|
||||
const id = message?.id
|
||||
if (!id) continue
|
||||
delete draft.part[id]
|
||||
}
|
||||
}
|
||||
delete draft.message[sessionID]
|
||||
delete draft.session_diff[sessionID]
|
||||
delete draft.todo[sessionID]
|
||||
delete draft.permission[sessionID]
|
||||
delete draft.question[sessionID]
|
||||
delete draft.session_status[sessionID]
|
||||
dropSessionCaches(draft, [sessionID])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function cleanupDroppedSessionCaches(
|
||||
store: Store<State>,
|
||||
setStore: SetStoreFunction<State>,
|
||||
next: Session[],
|
||||
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
|
||||
) {
|
||||
const keep = new Set(next.map((item) => item.id))
|
||||
const stale = [
|
||||
...Object.keys(store.message),
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.question),
|
||||
...Object.keys(store.session_status),
|
||||
...Object.values(store.part)
|
||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||
.filter((sessionID): sessionID is string => !!sessionID),
|
||||
].filter((sessionID, index, list) => !keep.has(sessionID) && list.indexOf(sessionID) === index)
|
||||
if (stale.length === 0) return
|
||||
for (const sessionID of stale) {
|
||||
setSessionTodo?.(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -102,6 +110,7 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
|
||||
break
|
||||
}
|
||||
@@ -117,7 +126,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
@@ -130,6 +139,7 @@ export function applyDirectoryEvent(input: {
|
||||
next.splice(result.index, 0, info)
|
||||
const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
|
||||
input.setStore("session", reconcile(trimmed, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(input.store, input.setStore, trimmed, input.setSessionTodo)
|
||||
break
|
||||
}
|
||||
case "session.deleted": {
|
||||
@@ -143,7 +153,7 @@ export function applyDirectoryEvent(input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
|
||||
cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo)
|
||||
if (info.parentID) break
|
||||
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
|
||||
break
|
||||
|
||||
102
packages/app/src/context/global-sync/session-cache.test.ts
Normal file
102
packages/app/src/context/global-sync/session-cache.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "assistant",
|
||||
model: { providerID: "openai", modelID: "gpt" },
|
||||
}) as Message
|
||||
|
||||
const part = (id: string, sessionID: string, messageID: string) =>
|
||||
({
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: id,
|
||||
}) as Part
|
||||
|
||||
describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] as Todo[] },
|
||||
message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part.msg_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
test("dropSessionCaches clears message-backed parts", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
message: { ses_1: [m] },
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
}
|
||||
|
||||
dropSessionCaches(store, ["ses_1"])
|
||||
|
||||
expect(store.message.ses_1).toBeUndefined()
|
||||
expect(store.part[m.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("pickSessionCacheEvictions preserves requested sessions", () => {
|
||||
const seen = new Set(["ses_1", "ses_2", "ses_3"])
|
||||
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen,
|
||||
keep: "ses_4",
|
||||
limit: 2,
|
||||
preserve: ["ses_1"],
|
||||
})
|
||||
|
||||
expect(stale).toEqual(["ses_2", "ses_3"])
|
||||
expect([...seen]).toEqual(["ses_1", "ses_4"])
|
||||
})
|
||||
})
|
||||
62
packages/app/src/context/global-sync/session-cache.ts
Normal file
62
packages/app/src/context/global-sync/session-cache.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
FileDiff,
|
||||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiff[] | undefined>
|
||||
todo: Record<string, Todo[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
}
|
||||
|
||||
export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<string>) {
|
||||
const stale = new Set(Array.from(sessionIDs).filter(Boolean))
|
||||
if (stale.size === 0) return
|
||||
|
||||
for (const key of Object.keys(store.part)) {
|
||||
const parts = store.part[key]
|
||||
if (!parts?.some((part) => stale.has(part?.sessionID ?? ""))) continue
|
||||
delete store.part[key]
|
||||
}
|
||||
|
||||
for (const sessionID of stale) {
|
||||
delete store.message[sessionID]
|
||||
delete store.todo[sessionID]
|
||||
delete store.session_diff[sessionID]
|
||||
delete store.session_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
delete store.question[sessionID]
|
||||
}
|
||||
}
|
||||
|
||||
export function pickSessionCacheEvictions(input: {
|
||||
seen: Set<string>
|
||||
keep: string
|
||||
limit: number
|
||||
preserve?: Iterable<string>
|
||||
}) {
|
||||
const stale: string[] = []
|
||||
const keep = new Set([input.keep, ...Array.from(input.preserve ?? [])])
|
||||
if (input.seen.has(input.keep)) input.seen.delete(input.keep)
|
||||
input.seen.add(input.keep)
|
||||
for (const id of input.seen) {
|
||||
if (input.seen.size - stale.length <= input.limit) break
|
||||
if (keep.has(id)) continue
|
||||
stale.push(id)
|
||||
}
|
||||
for (const id of stale) {
|
||||
input.seen.delete(id)
|
||||
}
|
||||
return stale
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
|
||||
|
||||
function sortParts(parts: Part[]) {
|
||||
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
|
||||
@@ -108,6 +109,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const inflight = new Map<string, Promise<void>>()
|
||||
const inflightDiff = new Map<string, Promise<void>>()
|
||||
const inflightTodo = new Map<string, Promise<void>>()
|
||||
const maxDirs = 30
|
||||
const seen = new Map<string, Set<string>>()
|
||||
const [meta, setMeta] = createStore({
|
||||
limit: {} as Record<string, number>,
|
||||
complete: {} as Record<string, boolean>,
|
||||
@@ -121,6 +124,62 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
return undefined
|
||||
}
|
||||
|
||||
const seenFor = (directory: string) => {
|
||||
const existing = seen.get(directory)
|
||||
if (existing) {
|
||||
seen.delete(directory)
|
||||
seen.set(directory, existing)
|
||||
return existing
|
||||
}
|
||||
const created = new Set<string>()
|
||||
seen.set(directory, created)
|
||||
while (seen.size > maxDirs) {
|
||||
const first = seen.keys().next().value
|
||||
if (!first) break
|
||||
const stale = [...(seen.get(first) ?? [])]
|
||||
seen.delete(first)
|
||||
const [, setStore] = globalSync.child(first, { bootstrap: false })
|
||||
evict(first, setStore, stale)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
const clearMeta = (directory: string, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
setMeta(
|
||||
produce((draft) => {
|
||||
for (const sessionID of sessionIDs) {
|
||||
const key = keyFor(directory, sessionID)
|
||||
delete draft.limit[key]
|
||||
delete draft.complete[key]
|
||||
delete draft.loading[key]
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
for (const sessionID of sessionIDs) {
|
||||
globalSync.todo.set(sessionID, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, sessionIDs)
|
||||
}),
|
||||
)
|
||||
clearMeta(directory, sessionIDs)
|
||||
}
|
||||
|
||||
const touch = (directory: string, setStore: Setter, sessionID: string) => {
|
||||
const stale = pickSessionCacheEvictions({
|
||||
seen: seenFor(directory),
|
||||
keep: sessionID,
|
||||
limit: SESSION_CACHE_LIMIT,
|
||||
})
|
||||
evict(directory, setStore, stale)
|
||||
}
|
||||
|
||||
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
|
||||
const messages = await retry(() =>
|
||||
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
|
||||
@@ -135,6 +194,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
}
|
||||
}
|
||||
|
||||
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
|
||||
|
||||
const loadMessages = async (input: {
|
||||
directory: string
|
||||
client: typeof sdk.client
|
||||
@@ -148,6 +209,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
setMeta("loading", key, true)
|
||||
await fetchMessages(input)
|
||||
.then((next) => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
batch(() => {
|
||||
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
|
||||
for (const p of next.part) {
|
||||
@@ -158,6 +220,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!tracked(input.directory, input.sessionID)) return
|
||||
setMeta("loading", key, false)
|
||||
})
|
||||
}
|
||||
@@ -224,11 +287,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(directory, sessionID)
|
||||
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
|
||||
|
||||
touch(directory, setStore, sessionID)
|
||||
|
||||
if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return
|
||||
|
||||
const limit = meta.limit[key] ?? messagePageSize
|
||||
|
||||
const sessionReq = hasSession
|
||||
? Promise.resolve()
|
||||
: retry(() => client.session.get({ sessionID })).then((session) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const data = session.data
|
||||
if (!data) return
|
||||
setStore(
|
||||
@@ -258,11 +326,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
if (store.session_diff[sessionID] !== undefined) return
|
||||
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightDiff, key, () =>
|
||||
retry(() => client.session.diff({ sessionID })).then((diff) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
|
||||
}),
|
||||
)
|
||||
@@ -271,6 +341,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [store, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const existing = store.todo[sessionID]
|
||||
const cached = globalSync.data.session_todo[sessionID]
|
||||
if (existing !== undefined) {
|
||||
@@ -287,6 +358,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const key = keyFor(directory, sessionID)
|
||||
return runInflight(inflightTodo, key, () =>
|
||||
retry(() => client.session.todo({ sessionID })).then((todo) => {
|
||||
if (!tracked(directory, sessionID)) return
|
||||
const list = todo.data ?? []
|
||||
setStore("todo", sessionID, reconcile(list, { key: "id" }))
|
||||
globalSync.todo.set(sessionID, list)
|
||||
@@ -310,6 +382,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
touch(directory, setStore, sessionID)
|
||||
const key = keyFor(directory, sessionID)
|
||||
const step = count ?? messagePageSize
|
||||
if (meta.loading[key]) return
|
||||
@@ -325,6 +398,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
},
|
||||
evict(sessionID: string, directory = sdk.directory) {
|
||||
const [, setStore] = globalSync.child(directory)
|
||||
seenFor(directory).delete(sessionID)
|
||||
evict(directory, setStore, [sessionID])
|
||||
},
|
||||
fetch: async (count = 10) => {
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Platform } from "./platform"
|
||||
@@ -38,6 +38,16 @@ type TerminalCacheEntry = {
|
||||
|
||||
const caches = new Set<Map<string, TerminalCacheEntry>>()
|
||||
|
||||
const trimTerminal = (pty: LocalPTY) => {
|
||||
if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
|
||||
return {
|
||||
...pty,
|
||||
buffer: undefined,
|
||||
cursor: undefined,
|
||||
scrollY: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
for (const cache of caches) {
|
||||
@@ -188,6 +198,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
console.error("Failed to update terminal", error)
|
||||
})
|
||||
},
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
if (index === -1) return
|
||||
setStore("all", index, (pty) => trimTerminal(pty))
|
||||
},
|
||||
trimAll() {
|
||||
setStore("all", (all) => {
|
||||
const next = all.map(trimTerminal)
|
||||
if (next.every((pty, index) => pty === all[index])) return all
|
||||
return next
|
||||
})
|
||||
},
|
||||
async clone(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
@@ -322,12 +344,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (next.dir === prev.dir && next.id) return
|
||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
ready: () => workspace().ready(),
|
||||
all: () => workspace().all(),
|
||||
active: () => workspace().active(),
|
||||
new: () => workspace().new(),
|
||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useProviders } from "@/hooks/use-providers"
|
||||
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
|
||||
import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
@@ -423,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"
|
||||
@@ -657,25 +669,24 @@ export default function Layout(props: ParentProps) {
|
||||
const prefetchQueues = new Map<string, PrefetchQueue>()
|
||||
|
||||
const PREFETCH_MAX_SESSIONS_PER_DIR = 10
|
||||
const prefetchedByDir = new Map<string, Map<string, true>>()
|
||||
const prefetchedByDir = new Map<string, Set<string>>()
|
||||
|
||||
const lruFor = (directory: string) => {
|
||||
const existing = prefetchedByDir.get(directory)
|
||||
if (existing) return existing
|
||||
const created = new Map<string, true>()
|
||||
const created = new Set<string>()
|
||||
prefetchedByDir.set(directory, created)
|
||||
return created
|
||||
}
|
||||
|
||||
const markPrefetched = (directory: string, sessionID: string) => {
|
||||
const lru = lruFor(directory)
|
||||
if (lru.has(sessionID)) lru.delete(sessionID)
|
||||
lru.set(sessionID, true)
|
||||
while (lru.size > PREFETCH_MAX_SESSIONS_PER_DIR) {
|
||||
const oldest = lru.keys().next().value as string | undefined
|
||||
if (!oldest) return
|
||||
lru.delete(oldest)
|
||||
}
|
||||
return pickSessionCacheEvictions({
|
||||
seen: lru,
|
||||
keep: sessionID,
|
||||
limit: PREFETCH_MAX_SESSIONS_PER_DIR,
|
||||
preserve: directory === params.dir && params.id ? [params.id] : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
@@ -724,6 +735,7 @@ export default function Layout(props: ParentProps) {
|
||||
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
|
||||
.then((messages) => {
|
||||
if (prefetchToken.value !== token) return
|
||||
if (!lruFor(directory).has(sessionID)) return
|
||||
|
||||
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
|
||||
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
|
||||
@@ -787,7 +799,18 @@ export default function Layout(props: ParentProps) {
|
||||
const lru = lruFor(directory)
|
||||
const known = lru.has(session.id)
|
||||
if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
|
||||
markPrefetched(directory, session.id)
|
||||
const stale = markPrefetched(directory, session.id)
|
||||
if (stale.length > 0) {
|
||||
const [, setStore] = globalSync.child(directory, { bootstrap: false })
|
||||
for (const id of stale) {
|
||||
globalSync.todo.set(id, undefined)
|
||||
}
|
||||
setStore(
|
||||
produce((draft) => {
|
||||
dropSessionCaches(draft, stale)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (priority === "high") q.pending.unshift(session.id)
|
||||
if (priority !== "high") q.pending.push(session.id)
|
||||
@@ -1879,6 +1902,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
||||
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
|
||||
const projectName = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return ""
|
||||
@@ -1904,11 +1928,11 @@ export default function Layout(props: ParentProps) {
|
||||
return (
|
||||
<div
|
||||
classList={{
|
||||
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
|
||||
"flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-2": true,
|
||||
"border border-b-0 border-border-weak-base": !merged(),
|
||||
"border-l border-t border-border-weaker-base": merged(),
|
||||
"bg-background-base": merged(),
|
||||
"bg-background-stronger": !merged(),
|
||||
"bg-background-base": merged() || hover(),
|
||||
"bg-background-stronger": !merged() && !hover(),
|
||||
"flex-1 min-w-0": panelProps.mobile,
|
||||
"max-w-full overflow-hidden": panelProps.mobile,
|
||||
}}
|
||||
|
||||
@@ -91,6 +91,7 @@ const ProjectTile = (props: {
|
||||
modal={!props.sidebarHovering()}
|
||||
onOpenChange={(value) => {
|
||||
props.setMenu(value)
|
||||
props.setSuppressHover(value)
|
||||
if (value) props.setOpen(false)
|
||||
}}
|
||||
>
|
||||
@@ -107,6 +108,12 @@ const ProjectTile = (props: {
|
||||
!props.selected() && !props.active(),
|
||||
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
if (!props.overlay()) return
|
||||
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
|
||||
props.setSuppressHover(true)
|
||||
event.preventDefault()
|
||||
}}
|
||||
onMouseEnter={(event: MouseEvent) => {
|
||||
if (!props.overlay()) return
|
||||
if (props.suppressHover()) return
|
||||
|
||||
@@ -37,7 +37,6 @@ import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/message-timeline"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
@@ -122,9 +121,13 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
return
|
||||
}
|
||||
const beforeTop = el.scrollTop
|
||||
const beforeHeight = el.scrollHeight
|
||||
fn()
|
||||
void el.scrollHeight
|
||||
el.scrollTop = beforeTop
|
||||
requestAnimationFrame(() => {
|
||||
const delta = el.scrollHeight - beforeHeight
|
||||
if (!delta) return
|
||||
el.scrollTop = beforeTop + delta
|
||||
})
|
||||
}
|
||||
|
||||
const backfillTurns = () => {
|
||||
@@ -207,7 +210,7 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||
if (!input.userScrolled()) return
|
||||
const el = input.scroller()
|
||||
if (!el) return
|
||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||
if (el.scrollTop >= turnScrollThreshold) return
|
||||
|
||||
const start = turnStart()
|
||||
if (start > 0) {
|
||||
@@ -281,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,
|
||||
@@ -426,10 +430,12 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
(id, prev) => {
|
||||
if (id || !prev) return
|
||||
resetSessionModel(local)
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (prev.id) sync.session.evict(prev.id, prev.dir)
|
||||
if (!next.id) resetSessionModel(local)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -454,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()))
|
||||
|
||||
@@ -464,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<HTMLElement>("[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
|
||||
@@ -550,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
|
||||
|
||||
@@ -594,6 +646,7 @@ export default function Page() {
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("changes", "session")
|
||||
setUi("pendingMessage", undefined)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -1088,17 +1141,11 @@ export default function Page() {
|
||||
|
||||
let scrollStateFrame: number | undefined
|
||||
let scrollStateTarget: HTMLDivElement | 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 })
|
||||
@@ -1121,7 +1168,7 @@ export default function Page() {
|
||||
|
||||
const resumeScroll = () => {
|
||||
setStore("messageId", undefined)
|
||||
autoScroll.smoothScrollToBottom()
|
||||
autoScroll.forceScrollToBottom()
|
||||
clearMessageHash()
|
||||
|
||||
const el = scroller
|
||||
@@ -1141,31 +1188,21 @@ 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) scheduleScrollState(el)
|
||||
}
|
||||
|
||||
const markUserScroll = () => {
|
||||
scrollMark += 1
|
||||
}
|
||||
|
||||
createResizeObserver(
|
||||
() => content,
|
||||
() => {
|
||||
const el = scroller
|
||||
if (el) scheduleScrollState(el)
|
||||
scrollSpy.markDirty()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1189,14 +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()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1224,7 +1262,7 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyDown)
|
||||
scrollSpy.destroy()
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
})
|
||||
|
||||
@@ -1246,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(),
|
||||
@@ -1255,7 +1293,7 @@ export default function Page() {
|
||||
<div class="flex-1 min-h-0 overflow-hidden">
|
||||
<Switch>
|
||||
<Match when={params.id}>
|
||||
<Show when={activeMessage()}>
|
||||
<Show when={lastUserMessage()}>
|
||||
<MessageTimeline
|
||||
mobileChanges={mobileChanges()}
|
||||
mobileFallback={reviewContent({
|
||||
@@ -1275,11 +1313,9 @@ export default function Page() {
|
||||
onAutoScrollHandleScroll={autoScroll.handleScroll}
|
||||
onMarkScrollGesture={markScrollGesture}
|
||||
hasScrollGesture={hasScrollGesture}
|
||||
isDesktop={isDesktop()}
|
||||
onScrollSpyScroll={scrollSpy.onScroll}
|
||||
onUserScroll={markUserScroll}
|
||||
onTurnBackfillScroll={historyWindow.onScrollerScroll}
|
||||
onAutoScrollInteraction={autoScroll.handleInteraction}
|
||||
onPreserveScrollAnchor={autoScroll.preserve}
|
||||
centered={centered()}
|
||||
setContentRef={(el) => {
|
||||
content = el
|
||||
@@ -1296,8 +1332,6 @@ export default function Page() {
|
||||
}}
|
||||
renderedUserMessages={historyWindow.renderedUserMessages()}
|
||||
anchor={anchor}
|
||||
onRegisterMessage={scrollSpy.register}
|
||||
onUnregisterMessage={scrollSpy.unregister}
|
||||
/>
|
||||
</Show>
|
||||
</Match>
|
||||
@@ -1362,6 +1396,7 @@ export default function Page() {
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,7 @@ export function SessionComposerRegion(props: {
|
||||
<div
|
||||
classList={{
|
||||
"w-full px-3 pointer-events-auto": true,
|
||||
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<Show when={props.state.questionRequest()} keyed>
|
||||
|
||||
@@ -446,9 +446,9 @@ export function FileTabContent(props: { tab: string }) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Tabs.Content value={props.tab} class="mt-3 relative flex h-full min-h-0 flex-col overflow-hidden contain-strict">
|
||||
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
|
||||
<ScrollView
|
||||
class="h-full min-h-0 flex-1"
|
||||
class="h-full"
|
||||
viewportRef={(el: HTMLDivElement) => {
|
||||
scroll = el
|
||||
restoreScroll()
|
||||
|
||||
@@ -1,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<string>([sessionID])
|
||||
|
||||
const byParent = new Map<string, string[]>()
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((s) => !removed.has(s.id))
|
||||
}),
|
||||
)
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(props: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(props.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show
|
||||
@@ -336,18 +530,7 @@ export function MessageTimeline(props: {
|
||||
<Icon name="arrow-down-to-line" />
|
||||
</button>
|
||||
</div>
|
||||
<SessionTimelineHeader
|
||||
centered={props.centered}
|
||||
showHeader={showHeader}
|
||||
sessionKey={sessionKey}
|
||||
sessionID={sessionID}
|
||||
parentID={parentID}
|
||||
titleValue={titleValue}
|
||||
headerTitle={headerTitle}
|
||||
placeholderTitle={placeholderTitle}
|
||||
/>
|
||||
<ScrollView
|
||||
reverse
|
||||
viewportRef={props.setScrollRef}
|
||||
onWheel={(e) => {
|
||||
const root = e.currentTarget
|
||||
@@ -381,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",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div ref={props.setContentRef} class="min-w-0 w-full">
|
||||
<Show when={showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
classList={{
|
||||
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-4": true,
|
||||
"pl-2 pr-3 md:pl-4 md:pr-3": true,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
|
||||
<Show when={parentID()}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<div class="flex items-center min-w-0 grow-1">
|
||||
<div
|
||||
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
style={{
|
||||
width: slot.open ? "16px" : "0px",
|
||||
"margin-right": slot.open ? "8px" : "0px",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Show when={slot.show}>
|
||||
<div
|
||||
class="transition-opacity duration-200 ease-out"
|
||||
classList={{
|
||||
"opacity-0": slot.fade,
|
||||
}}
|
||||
>
|
||||
<Spinner class="size-4" style={{ color: "var(--icon-interactive-base)" }} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={titleValue() || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1
|
||||
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
|
||||
onDblClick={openTitleEditor}
|
||||
>
|
||||
{titleValue()}
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={props.setContentRef}
|
||||
role="log"
|
||||
class="flex flex-col gap-0 items-start justify-start pb-16 transition-[margin]"
|
||||
style={{ "padding-top": "var(--session-title-height)" }}
|
||||
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
|
||||
classList={{
|
||||
"w-full": true,
|
||||
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
||||
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
|
||||
"mt-0.5": props.centered,
|
||||
"mt-0": !props.centered,
|
||||
}}
|
||||
@@ -440,15 +745,6 @@ export function MessageTimeline(props: {
|
||||
</Show>
|
||||
<For each={rendered()}>
|
||||
{(messageID) => {
|
||||
// Capture at creation time: animate only messages added after the
|
||||
// timeline finishes its initial backfill staging, plus the first
|
||||
// turn while a brand new session is still using its default title.
|
||||
const isNew =
|
||||
staging.ready() ||
|
||||
(defaultTitle() &&
|
||||
sessionStatus() !== "idle" &&
|
||||
props.renderedUserMessages.length === 1 &&
|
||||
messageID === props.renderedUserMessages[0]?.id)
|
||||
const active = createMemo(() => activeMessageID() === messageID)
|
||||
const queued = createMemo(() => {
|
||||
if (active()) return false
|
||||
@@ -457,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 (
|
||||
<div
|
||||
id={props.anchor(messageID)}
|
||||
data-message-id={messageID}
|
||||
ref={(el) => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Show when={commentCount() > 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()}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, HTMLElement>()
|
||||
const id = new WeakMap<HTMLElement, string>()
|
||||
const visible = new Map<string, { ratio: number; top: number }>()
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewSnap: boolean
|
||||
size: Sizing
|
||||
}) {
|
||||
const params = useParams()
|
||||
@@ -228,7 +229,7 @@ export function SessionSidePanel(props: {
|
||||
classList={{
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active(),
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
|
||||
@@ -1,522 +0,0 @@
|
||||
import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
|
||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { errorMessage } from "@/pages/layout/helpers"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
|
||||
export function SessionTimelineHeader(props: {
|
||||
centered: boolean
|
||||
showHeader: () => boolean
|
||||
sessionKey: () => string
|
||||
sessionID: () => string | undefined
|
||||
parentID: () => string | undefined
|
||||
titleValue: () => string | undefined
|
||||
headerTitle: () => string | undefined
|
||||
placeholderTitle: () => boolean
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const reduce = prefersReducedMotion
|
||||
|
||||
const [title, setTitle] = createStore({
|
||||
draft: "",
|
||||
editing: false,
|
||||
saving: false,
|
||||
menuOpen: false,
|
||||
pendingRename: false,
|
||||
})
|
||||
const [headerText, setHeaderText] = createStore({
|
||||
session: props.sessionKey(),
|
||||
value: props.headerTitle(),
|
||||
prev: undefined as string | undefined,
|
||||
muted: props.placeholderTitle(),
|
||||
prevMuted: false,
|
||||
})
|
||||
let headerAnim: AnimationPlaybackControls | undefined
|
||||
let enterAnim: AnimationPlaybackControls | undefined
|
||||
let leaveAnim: AnimationPlaybackControls | undefined
|
||||
let titleRef: HTMLInputElement | undefined
|
||||
let headerRef: HTMLDivElement | undefined
|
||||
let enterRef: HTMLSpanElement | undefined
|
||||
let leaveRef: HTMLSpanElement | undefined
|
||||
|
||||
const clearHeaderAnim = () => {
|
||||
headerAnim?.stop()
|
||||
headerAnim = undefined
|
||||
}
|
||||
|
||||
const animateHeader = () => {
|
||||
const el = headerRef
|
||||
if (!el) return
|
||||
|
||||
clearHeaderAnim()
|
||||
if (!headerText.muted || reduce()) {
|
||||
el.style.opacity = "1"
|
||||
return
|
||||
}
|
||||
|
||||
headerAnim = animate(el, { opacity: [0, 1] }, { type: "spring", visualDuration: 1.0, bounce: 0 })
|
||||
headerAnim.finished.then(() => {
|
||||
if (headerRef !== el) return
|
||||
clearFadeStyles(el)
|
||||
})
|
||||
}
|
||||
|
||||
const clearTitleAnims = () => {
|
||||
enterAnim?.stop()
|
||||
enterAnim = undefined
|
||||
leaveAnim?.stop()
|
||||
leaveAnim = undefined
|
||||
}
|
||||
|
||||
const settleTitleEnter = () => {
|
||||
if (enterRef) clearFadeStyles(enterRef)
|
||||
}
|
||||
|
||||
const hideLeave = () => {
|
||||
if (!leaveRef) return
|
||||
leaveRef.style.opacity = "0"
|
||||
leaveRef.style.filter = ""
|
||||
leaveRef.style.transform = ""
|
||||
}
|
||||
|
||||
const animateEnterSpan = () => {
|
||||
if (!enterRef) return
|
||||
if (reduce()) {
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
enterAnim = animate(
|
||||
enterRef,
|
||||
{ opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
enterAnim.finished.then(() => settleTitleEnter())
|
||||
}
|
||||
|
||||
const crossfadeTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ prev: headerText.value, prevMuted: headerText.muted })
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted })
|
||||
|
||||
if (reduce()) {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
settleTitleEnter()
|
||||
return
|
||||
}
|
||||
|
||||
if (leaveRef) {
|
||||
leaveAnim = animate(
|
||||
leaveRef,
|
||||
{ opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"], transform: ["translateY(0)", "translateY(2px)"] },
|
||||
FAST_SPRING,
|
||||
)
|
||||
leaveAnim.finished.then(() => {
|
||||
setHeaderText({ prev: undefined, prevMuted: false })
|
||||
hideLeave()
|
||||
})
|
||||
}
|
||||
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const fadeInTitle = (nextTitle: string, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
animateEnterSpan()
|
||||
}
|
||||
|
||||
const snapTitle = (nextTitle: string | undefined, nextMuted: boolean) => {
|
||||
clearTitleAnims()
|
||||
setHeaderText({ value: nextTitle, muted: nextMuted, prev: undefined, prevMuted: false })
|
||||
settleTitleEnter()
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(props.showHeader, (show, prev) => {
|
||||
if (!show) {
|
||||
clearHeaderAnim()
|
||||
return
|
||||
}
|
||||
if (show === prev) return
|
||||
animateHeader()
|
||||
}),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [props.sessionKey(), props.headerTitle(), props.placeholderTitle()] as const,
|
||||
([nextSession, nextTitle, nextMuted]) => {
|
||||
if (nextSession !== headerText.session) {
|
||||
setHeaderText("session", nextSession)
|
||||
if (nextTitle && nextMuted) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (nextTitle === headerText.value && nextMuted === headerText.muted) return
|
||||
if (!nextTitle) {
|
||||
snapTitle(undefined, false)
|
||||
return
|
||||
}
|
||||
if (!headerText.value) {
|
||||
fadeInTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
if (title.saving || title.editing) {
|
||||
snapTitle(nextTitle, nextMuted)
|
||||
return
|
||||
}
|
||||
crossfadeTitle(nextTitle, nextMuted)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
onCleanup(() => {
|
||||
clearHeaderAnim()
|
||||
clearTitleAnims()
|
||||
})
|
||||
|
||||
const toastError = (err: unknown) => errorMessage(err, language.t("common.requestFailed"))
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
props.sessionKey,
|
||||
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const openTitleEditor = () => {
|
||||
if (!props.sessionID()) return
|
||||
setTitle({ editing: true, draft: props.titleValue() ?? "" })
|
||||
requestAnimationFrame(() => {
|
||||
titleRef?.focus()
|
||||
titleRef?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const closeTitleEditor = () => {
|
||||
if (title.saving) return
|
||||
setTitle({ editing: false, saving: false })
|
||||
}
|
||||
|
||||
const saveTitleEditor = async () => {
|
||||
const id = props.sessionID()
|
||||
if (!id) return
|
||||
if (title.saving) return
|
||||
|
||||
const next = title.draft.trim()
|
||||
if (!next || next === (props.titleValue() ?? "")) {
|
||||
setTitle({ editing: false, saving: false })
|
||||
return
|
||||
}
|
||||
|
||||
setTitle("saving", true)
|
||||
await sdk.client.session
|
||||
.update({ sessionID: id, title: next })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
setTitle({ editing: false, saving: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
setTitle("saving", false)
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||
if (params.id !== sessionID) return
|
||||
if (parentID) {
|
||||
navigate(`/${params.dir}/session/${parentID}`)
|
||||
return
|
||||
}
|
||||
if (nextSessionID) {
|
||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
||||
return
|
||||
}
|
||||
navigate(`/${params.dir}/session`)
|
||||
}
|
||||
|
||||
const archiveSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return
|
||||
|
||||
const sessions = sync.data.session ?? []
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
await sdk.client.session
|
||||
.update({ sessionID, time: { archived: Date.now() } })
|
||||
.then(() => {
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((item) => item.id === sessionID)
|
||||
if (index !== -1) draft.session.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: toastError(err),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const deleteSession = async (sessionID: string) => {
|
||||
const session = sync.session.get(sessionID)
|
||||
if (!session) return false
|
||||
|
||||
const sessions = (sync.data.session ?? []).filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === sessionID)
|
||||
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
|
||||
const result = await sdk.client.session
|
||||
.delete({ sessionID })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
title: language.t("session.delete.failed.title"),
|
||||
description: toastError(err),
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!result) return false
|
||||
|
||||
sync.set(
|
||||
produce((draft) => {
|
||||
const removed = new Set<string>([sessionID])
|
||||
const byParent = new Map<string, string[]>()
|
||||
|
||||
for (const item of draft.session) {
|
||||
const parentID = item.parentID
|
||||
if (!parentID) continue
|
||||
|
||||
const existing = byParent.get(parentID)
|
||||
if (existing) {
|
||||
existing.push(item.id)
|
||||
continue
|
||||
}
|
||||
byParent.set(parentID, [item.id])
|
||||
}
|
||||
|
||||
const stack = [sessionID]
|
||||
while (stack.length) {
|
||||
const parentID = stack.pop()
|
||||
if (!parentID) continue
|
||||
|
||||
const children = byParent.get(parentID)
|
||||
if (!children) continue
|
||||
|
||||
for (const child of children) {
|
||||
if (removed.has(child)) continue
|
||||
removed.add(child)
|
||||
stack.push(child)
|
||||
}
|
||||
}
|
||||
|
||||
draft.session = draft.session.filter((item) => !removed.has(item.id))
|
||||
}),
|
||||
)
|
||||
|
||||
navigateAfterSessionRemoval(sessionID, session.parentID, nextSession?.id)
|
||||
return true
|
||||
}
|
||||
|
||||
const navigateParent = () => {
|
||||
const id = props.parentID()
|
||||
if (!id) return
|
||||
navigate(`/${params.dir}/session/${id}`)
|
||||
}
|
||||
|
||||
function DialogDeleteSession(input: { sessionID: string }) {
|
||||
const name = createMemo(() => sync.session.get(input.sessionID)?.title ?? language.t("command.session.new"))
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteSession(input.sessionID)
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("session.delete.title")} fit>
|
||||
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-14-regular text-text-strong">
|
||||
{language.t("session.delete.confirm", { name: name() })}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="large" onClick={handleDelete}>
|
||||
{language.t("session.delete.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.showHeader()}>
|
||||
<div
|
||||
data-session-title
|
||||
ref={(el) => {
|
||||
headerRef = el
|
||||
el.style.opacity = "0"
|
||||
}}
|
||||
class="pointer-events-none absolute inset-x-0 top-0 z-30"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"bg-[linear-gradient(to_bottom,var(--background-stronger)_38px,transparent)]": true,
|
||||
"w-full": true,
|
||||
"pb-10": true,
|
||||
"px-4 md:px-5": true,
|
||||
"md:max-w-[500px] md:mx-auto 2xl:max-w-[700px]": props.centered,
|
||||
}}
|
||||
>
|
||||
<div class="pointer-events-auto h-12 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={props.parentID()}>
|
||||
<div>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={navigateParent}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={!!headerText.value || title.editing}>
|
||||
<Show
|
||||
when={title.editing}
|
||||
fallback={
|
||||
<h1 class="text-14-medium text-text-strong grow-1 min-w-0" onDblClick={openTitleEditor}>
|
||||
<span class="grid min-w-0" style={{ overflow: "clip" }}>
|
||||
<span ref={enterRef} class="col-start-1 row-start-1 min-w-0 truncate">
|
||||
<span classList={{ "opacity-60": headerText.muted }}>{headerText.value}</span>
|
||||
</span>
|
||||
<span
|
||||
ref={leaveRef}
|
||||
class="col-start-1 row-start-1 min-w-0 truncate pointer-events-none"
|
||||
style={{ opacity: "0" }}
|
||||
>
|
||||
<span classList={{ "opacity-60": headerText.prevMuted }}>{headerText.prev}</span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
}
|
||||
>
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
titleRef = el
|
||||
}}
|
||||
value={title.draft}
|
||||
disabled={title.saving}
|
||||
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
|
||||
style={{ "--inline-input-shadow": "var(--shadow-xs-border-select)" }}
|
||||
onInput={(event) => setTitle("draft", event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
void saveTitleEditor()
|
||||
return
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeTitleEditor()
|
||||
}
|
||||
}}
|
||||
onBlur={closeTitleEditor}
|
||||
/>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.sessionID()}>
|
||||
{(id) => (
|
||||
<div class="shrink-0 flex items-center gap-3">
|
||||
<SessionContextUsage placement="bottom" />
|
||||
<DropdownMenu
|
||||
gutter={4}
|
||||
placement="bottom-end"
|
||||
open={title.menuOpen}
|
||||
onOpenChange={(open) => setTitle("menuOpen", open)}
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="dot-grid"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
|
||||
aria-label={language.t("common.moreOptions")}
|
||||
/>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{ "min-width": "104px" }}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!title.pendingRename) return
|
||||
event.preventDefault()
|
||||
setTitle("pendingRename", false)
|
||||
openTitleEditor()
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
setTitle("pendingRename", true)
|
||||
setTitle("menuOpen", false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}>
|
||||
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
@@ -250,6 +250,7 @@ export function TerminalPanel() {
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
onConnect={() => terminal.trim(id)}
|
||||
onCleanup={terminal.update}
|
||||
onConnectError={() => terminal.clone(id)}
|
||||
/>
|
||||
|
||||
@@ -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<number>()
|
||||
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,
|
||||
|
||||
46
packages/app/src/theme-preload.test.ts
Normal file
46
packages/app/src/theme-preload.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
const src = await Bun.file(new URL("../public/oc-theme-preload.js", import.meta.url)).text()
|
||||
|
||||
const run = () => Function(src)()
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = ""
|
||||
document.documentElement.removeAttribute("data-theme")
|
||||
document.documentElement.removeAttribute("data-color-scheme")
|
||||
localStorage.clear()
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
value: () =>
|
||||
({
|
||||
matches: false,
|
||||
}) as MediaQueryList,
|
||||
configurable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe("theme preload", () => {
|
||||
test("migrates legacy oc-1 to oc-2 before mount", () => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-1")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
localStorage.setItem("opencode-theme-css-dark", "--background-base:#000;")
|
||||
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("oc-2")
|
||||
expect(document.documentElement.dataset.colorScheme).toBe("light")
|
||||
expect(localStorage.getItem("opencode-theme-id")).toBe("oc-2")
|
||||
expect(localStorage.getItem("opencode-theme-css-light")).toBeNull()
|
||||
expect(localStorage.getItem("opencode-theme-css-dark")).toBeNull()
|
||||
expect(document.getElementById("oc-theme-preload")).toBeNull()
|
||||
})
|
||||
|
||||
test("keeps cached css for non-default themes", () => {
|
||||
localStorage.setItem("opencode-theme-id", "nightowl")
|
||||
localStorage.setItem("opencode-theme-css-light", "--background-base:#fff;")
|
||||
|
||||
run()
|
||||
|
||||
expect(document.documentElement.dataset.theme).toBe("nightowl")
|
||||
expect(document.getElementById("oc-theme-preload")?.textContent).toContain("--background-base:#fff;")
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -204,7 +204,7 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
|
||||
}
|
||||
|
||||
function workspaceStorage(dir: string) {
|
||||
const head = dir.slice(0, 12) || "workspace"
|
||||
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const sum = checksum(dir) ?? "0"
|
||||
return `opencode.workspace.${head}.${sum}.dat`
|
||||
}
|
||||
@@ -300,6 +300,7 @@ export const PersistTesting = {
|
||||
localStorageDirect,
|
||||
localStorageWithPrefix,
|
||||
normalize,
|
||||
workspaceStorage,
|
||||
}
|
||||
|
||||
export const Persist = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.21"
|
||||
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.21/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.21/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.21/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.21/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.21/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.24/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -111,7 +112,6 @@ export function tui(input: {
|
||||
fetch?: typeof fetch
|
||||
headers?: RequestInit["headers"]
|
||||
events?: EventSource
|
||||
onExit?: () => Promise<void>
|
||||
}) {
|
||||
// promise to prevent immediate exit
|
||||
return new Promise<void>(async (resolve) => {
|
||||
@@ -126,7 +126,6 @@ export function tui(input: {
|
||||
|
||||
const onExit = async () => {
|
||||
unguard?.()
|
||||
await input.onExit?.()
|
||||
resolve()
|
||||
}
|
||||
|
||||
@@ -373,6 +372,22 @@ function App() {
|
||||
dialog.replace(() => <DialogSessionList />)
|
||||
},
|
||||
},
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI
|
||||
? [
|
||||
{
|
||||
title: "Manage workspaces",
|
||||
value: "workspace.list",
|
||||
category: "Workspace",
|
||||
suggested: true,
|
||||
slash: {
|
||||
name: "workspaces",
|
||||
},
|
||||
onSelect: () => {
|
||||
dialog.replace(() => <DialogWorkspaceList />)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: "New session",
|
||||
suggested: route.data.type === "session",
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
|
||||
import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../context/sdk"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useKeybind } from "../context/keybind"
|
||||
import { DialogSessionList } from "./workspace/dialog-session-list"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
async function openWorkspace(input: {
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
route: ReturnType<typeof useRoute>
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
toast: ReturnType<typeof useToast>
|
||||
workspaceID: string
|
||||
forceCreate?: boolean
|
||||
}) {
|
||||
const cacheSession = (session: Session) => {
|
||||
input.sync.set(
|
||||
"session",
|
||||
[...input.sync.data.session.filter((item) => item.id !== session.id), session].toSorted((a, b) =>
|
||||
a.id.localeCompare(b.id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
})
|
||||
const listed = input.forceCreate ? undefined : await client.session.list({ roots: true, limit: 1 })
|
||||
const session = listed?.data?.[0]
|
||||
if (session?.id) {
|
||||
cacheSession(session)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: session.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
return
|
||||
}
|
||||
let created: Session | undefined
|
||||
while (!created) {
|
||||
const result = await client.session.create({}).catch(() => undefined)
|
||||
if (!result) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (result.response.status >= 500 && result.response.status < 600) {
|
||||
await Bun.sleep(1000)
|
||||
continue
|
||||
}
|
||||
if (!result.data) {
|
||||
input.toast.show({
|
||||
message: "Failed to open workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
created = result.data
|
||||
}
|
||||
cacheSession(created)
|
||||
input.route.navigate({
|
||||
type: "session",
|
||||
sessionID: created.id,
|
||||
})
|
||||
input.dialog.clear()
|
||||
}
|
||||
|
||||
function DialogWorkspaceCreate(props: { onSelect: (workspaceID: string) => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const [creating, setCreating] = createSignal<string>()
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const type = creating()
|
||||
if (type) {
|
||||
return [
|
||||
{
|
||||
title: `Creating ${type} workspace...`,
|
||||
value: "creating" as const,
|
||||
description: "This can take a while for remote environments",
|
||||
},
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: "Worktree",
|
||||
value: "worktree" as const,
|
||||
description: "Create a local git worktree",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const createWorkspace = async (type: string) => {
|
||||
if (creating()) return
|
||||
setCreating(type)
|
||||
|
||||
const result = await sdk.client.experimental.workspace.create({ type, branch: null }).catch((err) => {
|
||||
console.log(err)
|
||||
return undefined
|
||||
})
|
||||
console.log(JSON.stringify(result, null, 2))
|
||||
const workspace = result?.data
|
||||
if (!workspace) {
|
||||
setCreating(undefined)
|
||||
toast.show({
|
||||
message: "Failed to create workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
await props.onSelect(workspace.id)
|
||||
setCreating(undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={creating() ? "Creating Workspace" : "New Workspace"}
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
onSelect={(option) => {
|
||||
if (option.value === "creating") return
|
||||
void createWorkspace(option.value)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogWorkspaceList() {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const toast = useToast()
|
||||
const keybind = useKeybind()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [counts, setCounts] = createSignal<Record<string, number | null | undefined>>({})
|
||||
|
||||
const open = (workspaceID: string, forceCreate?: boolean) =>
|
||||
openWorkspace({
|
||||
dialog,
|
||||
route,
|
||||
sdk,
|
||||
sync,
|
||||
toast,
|
||||
workspaceID,
|
||||
forceCreate,
|
||||
})
|
||||
|
||||
async function selectWorkspace(workspaceID: string) {
|
||||
if (workspaceID === "__local__") {
|
||||
if (localCount() > 0) {
|
||||
dialog.replace(() => <DialogSessionList localOnly={true} />)
|
||||
return
|
||||
}
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const count = counts()[workspaceID]
|
||||
if (count && count > 0) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await open(workspaceID)
|
||||
return
|
||||
}
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
const listed = await client.session.list({ roots: true, limit: 1 }).catch(() => undefined)
|
||||
if (listed?.data?.length) {
|
||||
dialog.replace(() => <DialogSessionList workspaceID={workspaceID} />)
|
||||
return
|
||||
}
|
||||
await open(workspaceID)
|
||||
}
|
||||
|
||||
const currentWorkspaceID = createMemo(() => {
|
||||
if (route.data.type === "session") {
|
||||
return sync.session.get(route.data.sessionID)?.workspaceID ?? "__local__"
|
||||
}
|
||||
return "__local__"
|
||||
})
|
||||
|
||||
const localCount = createMemo(
|
||||
() => sync.data.session.filter((session) => !session.workspaceID && !session.parentID).length,
|
||||
)
|
||||
|
||||
let run = 0
|
||||
createEffect(() => {
|
||||
const workspaces = sync.data.workspaceList
|
||||
const next = ++run
|
||||
if (!workspaces.length) {
|
||||
setCounts({})
|
||||
return
|
||||
}
|
||||
setCounts(Object.fromEntries(workspaces.map((workspace) => [workspace.id, undefined])))
|
||||
void Promise.all(
|
||||
workspaces.map(async (workspace) => {
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: sdk.url,
|
||||
fetch: sdk.fetch,
|
||||
directory: sync.data.path.directory || sdk.directory,
|
||||
experimental_workspaceID: workspace.id,
|
||||
})
|
||||
const result = await client.session.list({ roots: true }).catch(() => undefined)
|
||||
return [workspace.id, result ? (result.data?.length ?? 0) : null] as const
|
||||
}),
|
||||
).then((entries) => {
|
||||
if (run !== next) return
|
||||
setCounts(Object.fromEntries(entries))
|
||||
})
|
||||
})
|
||||
|
||||
const options = createMemo(() => [
|
||||
{
|
||||
title: "Local",
|
||||
value: "__local__",
|
||||
category: "Workspace",
|
||||
description: "Use the local machine",
|
||||
footer: `${localCount()} session${localCount() === 1 ? "" : "s"}`,
|
||||
},
|
||||
...sync.data.workspaceList.map((workspace) => {
|
||||
const count = counts()[workspace.id]
|
||||
return {
|
||||
title:
|
||||
toDelete() === workspace.id
|
||||
? `Delete ${workspace.id}? Press ${keybind.print("session_delete")} again`
|
||||
: workspace.id,
|
||||
value: workspace.id,
|
||||
category: workspace.type,
|
||||
description: workspace.branch ? `Branch ${workspace.branch}` : undefined,
|
||||
footer:
|
||||
count === undefined
|
||||
? "Loading sessions..."
|
||||
: count === null
|
||||
? "Sessions unavailable"
|
||||
: `${count} session${count === 1 ? "" : "s"}`,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New workspace",
|
||||
value: "__create__",
|
||||
category: "Actions",
|
||||
description: "Create a new workspace",
|
||||
},
|
||||
])
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
void sync.workspace.sync()
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Workspaces"
|
||||
skipFilter={true}
|
||||
options={options()}
|
||||
current={currentWorkspaceID()}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
setToDelete(undefined)
|
||||
if (option.value === "__create__") {
|
||||
dialog.replace(() => <DialogWorkspaceCreate onSelect={(workspaceID) => open(workspaceID, true)} />)
|
||||
return
|
||||
}
|
||||
void selectWorkspace(option.value)
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (option.value === "__create__" || option.value === "__local__") return
|
||||
if (toDelete() !== option.value) {
|
||||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
const result = await sdk.client.experimental.workspace.remove({ id: option.value }).catch(() => undefined)
|
||||
setToDelete(undefined)
|
||||
if (result?.error) {
|
||||
toast.show({
|
||||
message: "Failed to delete workspace",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (currentWorkspaceID() === option.value) {
|
||||
route.navigate({
|
||||
type: "home",
|
||||
})
|
||||
}
|
||||
await sync.workspace.sync()
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||
import { useRoute } from "@tui/context/route"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
import { createMemo, createSignal, createResource, onMount, Show } from "solid-js"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { DialogSessionRename } from "../dialog-session-rename"
|
||||
import { useKV } from "../../context/kv"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { Spinner } from "../spinner"
|
||||
import { useToast } from "../../ui/toast"
|
||||
|
||||
export function DialogSessionList(props: { workspaceID?: string; localOnly?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const route = useRoute()
|
||||
const sync = useSync()
|
||||
const keybind = useKeybind()
|
||||
const { theme } = useTheme()
|
||||
const sdk = useSDK()
|
||||
const kv = useKV()
|
||||
const toast = useToast()
|
||||
const [toDelete, setToDelete] = createSignal<string>()
|
||||
const [search, setSearch] = createDebouncedSignal("", 150)
|
||||
|
||||
const [listed, listedActions] = createResource(
|
||||
() => props.workspaceID,
|
||||
async (workspaceID) => {
|
||||
if (!workspaceID) return undefined
|
||||
const result = await sdk.client.session.list({ roots: true })
|
||||
return result.data ?? []
|
||||
},
|
||||
)
|
||||
|
||||
const [searchResults] = createResource(search, async (query) => {
|
||||
if (!query || props.localOnly) return undefined
|
||||
const result = await sdk.client.session.list({
|
||||
search: query,
|
||||
limit: 30,
|
||||
...(props.workspaceID ? { roots: true } : {}),
|
||||
})
|
||||
return result.data ?? []
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
||||
const sessions = createMemo(() => {
|
||||
if (searchResults()) return searchResults()!
|
||||
if (props.workspaceID) return listed() ?? []
|
||||
if (props.localOnly) return sync.data.session.filter((session) => !session.workspaceID)
|
||||
return sync.data.session
|
||||
})
|
||||
|
||||
const options = createMemo(() => {
|
||||
const today = new Date().toDateString()
|
||||
return sessions()
|
||||
.filter((x) => {
|
||||
if (x.parentID !== undefined) return false
|
||||
if (props.workspaceID && listed()) return true
|
||||
if (props.workspaceID) return x.workspaceID === props.workspaceID
|
||||
if (props.localOnly) return !x.workspaceID
|
||||
return true
|
||||
})
|
||||
.toSorted((a, b) => b.time.updated - a.time.updated)
|
||||
.map((x) => {
|
||||
const date = new Date(x.time.updated)
|
||||
let category = date.toDateString()
|
||||
if (category === today) {
|
||||
category = "Today"
|
||||
}
|
||||
const isDeleting = toDelete() === x.id
|
||||
const status = sync.data.session_status?.[x.id]
|
||||
const isWorking = status?.type === "busy"
|
||||
return {
|
||||
title: isDeleting ? `Press ${keybind.print("session_delete")} again to confirm` : x.title,
|
||||
bg: isDeleting ? theme.error : undefined,
|
||||
value: x.id,
|
||||
category,
|
||||
footer: Locale.time(x.time.updated),
|
||||
gutter: isWorking ? <Spinner /> : undefined,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("large")
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title={props.workspaceID ? `Workspace Sessions` : props.localOnly ? "Local Sessions" : "Sessions"}
|
||||
options={options()}
|
||||
skipFilter={!props.localOnly}
|
||||
current={currentSessionID()}
|
||||
onFilter={setSearch}
|
||||
onMove={() => {
|
||||
setToDelete(undefined)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: option.value,
|
||||
})
|
||||
dialog.clear()
|
||||
}}
|
||||
keybind={[
|
||||
{
|
||||
keybind: keybind.all.session_delete?.[0],
|
||||
title: "delete",
|
||||
onTrigger: async (option) => {
|
||||
if (toDelete() === option.value) {
|
||||
const deleted = await sdk.client.session
|
||||
.delete({
|
||||
sessionID: option.value,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
setToDelete(undefined)
|
||||
if (!deleted) {
|
||||
toast.show({
|
||||
message: "Failed to delete session",
|
||||
variant: "error",
|
||||
})
|
||||
return
|
||||
}
|
||||
if (props.workspaceID) {
|
||||
listedActions.mutate((sessions) => sessions?.filter((session) => session.id !== option.value))
|
||||
return
|
||||
}
|
||||
sync.set(
|
||||
"session",
|
||||
sync.data.session.filter((session) => session.id !== option.value),
|
||||
)
|
||||
return
|
||||
}
|
||||
setToDelete(option.value)
|
||||
},
|
||||
},
|
||||
{
|
||||
keybind: keybind.all.session_rename?.[0],
|
||||
title: "rename",
|
||||
onTrigger: async (option) => {
|
||||
dialog.replace(() => <DialogSessionRename session={option.value} />)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
init: (input: { onExit?: () => Promise<void> }) => {
|
||||
const renderer = useRenderer()
|
||||
let message: string | undefined
|
||||
let task: Promise<void> | undefined
|
||||
const store = {
|
||||
set: (value?: string) => {
|
||||
const prev = message
|
||||
@@ -29,20 +30,24 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||
get: () => message,
|
||||
}
|
||||
const exit: Exit = Object.assign(
|
||||
async (reason?: unknown) => {
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
(reason?: unknown) => {
|
||||
if (task) return task
|
||||
task = (async () => {
|
||||
// Reset window title before destroying renderer
|
||||
renderer.setTerminalTitle("")
|
||||
renderer.destroy()
|
||||
win32FlushInputBuffer()
|
||||
if (reason) {
|
||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||
if (formatted) {
|
||||
process.stderr.write(formatted + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
await input.onExit?.()
|
||||
const text = store.get()
|
||||
if (text) process.stdout.write(text + "\n")
|
||||
await input.onExit?.()
|
||||
})()
|
||||
return task
|
||||
},
|
||||
{
|
||||
message: store,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { batch, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type EventSource = {
|
||||
on: (handler: (event: Event) => void) => () => void
|
||||
setWorkspace?: (workspaceID?: string) => void
|
||||
}
|
||||
|
||||
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
@@ -17,13 +18,21 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
events?: EventSource
|
||||
}) => {
|
||||
const abort = new AbortController()
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
})
|
||||
let workspaceID: string | undefined
|
||||
let sse: AbortController | undefined
|
||||
|
||||
function createSDK() {
|
||||
return createOpencodeClient({
|
||||
baseUrl: props.url,
|
||||
signal: abort.signal,
|
||||
directory: props.directory,
|
||||
fetch: props.fetch,
|
||||
headers: props.headers,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
}
|
||||
|
||||
let sdk = createSDK()
|
||||
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key in Event["type"]]: Extract<Event, { type: key }>
|
||||
@@ -61,41 +70,56 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
|
||||
flush()
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// If an event source is provided, use it instead of SSE
|
||||
function startSSE() {
|
||||
sse?.abort()
|
||||
const ctrl = new AbortController()
|
||||
sse = ctrl
|
||||
;(async () => {
|
||||
while (true) {
|
||||
if (abort.signal.aborted || ctrl.signal.aborted) break
|
||||
const events = await sdk.event.subscribe({}, { signal: ctrl.signal })
|
||||
|
||||
for await (const event of events.stream) {
|
||||
if (ctrl.signal.aborted) break
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) flush()
|
||||
}
|
||||
})().catch(() => {})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (props.events) {
|
||||
const unsub = props.events.on(handleEvent)
|
||||
onCleanup(unsub)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to SSE
|
||||
while (true) {
|
||||
if (abort.signal.aborted) break
|
||||
const events = await sdk.event.subscribe(
|
||||
{},
|
||||
{
|
||||
signal: abort.signal,
|
||||
},
|
||||
)
|
||||
|
||||
for await (const event of events.stream) {
|
||||
handleEvent(event)
|
||||
}
|
||||
|
||||
// Flush any remaining events
|
||||
if (timer) clearTimeout(timer)
|
||||
if (queue.length > 0) {
|
||||
flush()
|
||||
}
|
||||
} else {
|
||||
startSSE()
|
||||
}
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
sse?.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
})
|
||||
|
||||
return { client: sdk, event: emitter, url: props.url }
|
||||
return {
|
||||
get client() {
|
||||
return sdk
|
||||
},
|
||||
directory: props.directory,
|
||||
event: emitter,
|
||||
fetch: props.fetch ?? fetch,
|
||||
setWorkspace(next?: string) {
|
||||
if (workspaceID === next) return
|
||||
workspaceID = next
|
||||
sdk = createSDK()
|
||||
props.events?.setWorkspace?.(next)
|
||||
if (!props.events) startSSE()
|
||||
},
|
||||
url: props.url,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useArgs } from "./args"
|
||||
import { batch, onMount } from "solid-js"
|
||||
import { Log } from "@/util/log"
|
||||
import type { Path } from "@opencode-ai/sdk"
|
||||
import type { Workspace } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
name: "Sync",
|
||||
@@ -73,6 +74,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
formatter: FormatterStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
path: Path
|
||||
workspaceList: Workspace[]
|
||||
}>({
|
||||
provider_next: {
|
||||
all: [],
|
||||
@@ -100,10 +102,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
formatter: [],
|
||||
vcs: undefined,
|
||||
path: { state: "", config: "", worktree: "", directory: "" },
|
||||
workspaceList: [],
|
||||
})
|
||||
|
||||
const sdk = useSDK()
|
||||
|
||||
async function syncWorkspaces() {
|
||||
const result = await sdk.client.experimental.workspace.list().catch(() => undefined)
|
||||
if (!result?.data) return
|
||||
setStore("workspaceList", reconcile(result.data))
|
||||
}
|
||||
|
||||
sdk.event.listen((e) => {
|
||||
const event = e.details
|
||||
switch (event.type) {
|
||||
@@ -413,6 +422,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
sdk.client.provider.auth().then((x) => setStore("provider_auth", reconcile(x.data ?? {}))),
|
||||
sdk.client.vcs.get().then((x) => setStore("vcs", reconcile(x.data))),
|
||||
sdk.client.path.get().then((x) => setStore("path", reconcile(x.data!))),
|
||||
syncWorkspaces(),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
@@ -481,6 +491,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
fullSyncedSessions.add(sessionID)
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
get(workspaceID: string) {
|
||||
return store.workspaceList.find((workspace) => workspace.id === workspaceID)
|
||||
},
|
||||
sync: syncWorkspaces,
|
||||
},
|
||||
bootstrap,
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SplitBorder } from "@tui/component/border"
|
||||
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
|
||||
import { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
const Title = (props: { session: Accessor<Session> }) => {
|
||||
@@ -29,6 +30,17 @@ const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Acces
|
||||
)
|
||||
}
|
||||
|
||||
const WorkspaceInfo = (props: { workspace: Accessor<string | undefined> }) => {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<Show when={props.workspace()}>
|
||||
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
|
||||
{props.workspace()}
|
||||
</text>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const route = useRouteData("session")
|
||||
const sync = useSync()
|
||||
@@ -59,6 +71,14 @@ export function Header() {
|
||||
return result
|
||||
})
|
||||
|
||||
const workspace = createMemo(() => {
|
||||
const id = session()?.workspaceID
|
||||
if (!id) return "Workspace local"
|
||||
const info = sync.workspace.get(id)
|
||||
if (!info) return `Workspace ${id}`
|
||||
return `Workspace ${id} (${info.type})`
|
||||
})
|
||||
|
||||
const { theme } = useTheme()
|
||||
const keybind = useKeybind()
|
||||
const command = useCommandDialog()
|
||||
@@ -83,9 +103,19 @@ export function Header() {
|
||||
<Match when={session()?.parentID}>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={narrow() ? 1 : 0}>
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
|
||||
<box flexDirection="column">
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<text fg={theme.text}>
|
||||
<b>Subagent session</b>
|
||||
</text>
|
||||
)}
|
||||
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
@@ -124,7 +154,14 @@ export function Header() {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<box flexDirection={narrow() ? "column" : "row"} justifyContent="space-between" gap={1}>
|
||||
<Title session={session} />
|
||||
{Flag.OPENCODE_EXPERIMENTAL_WORKSPACES_TUI ? (
|
||||
<box flexDirection="column">
|
||||
<Title session={session} />
|
||||
<WorkspaceInfo workspace={workspace} />
|
||||
</box>
|
||||
) : (
|
||||
<Title session={session} />
|
||||
)}
|
||||
<ContextInfo context={context} cost={cost} />
|
||||
</box>
|
||||
</Match>
|
||||
|
||||
@@ -182,6 +182,12 @@ export function Session() {
|
||||
return new CustomSpeedScroll(3)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (session()?.workspaceID) {
|
||||
sdk.setWorkspace(session()?.workspaceID)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(async () => {
|
||||
await sync.session
|
||||
.sync(route.sessionID)
|
||||
|
||||
@@ -42,6 +42,9 @@ function createWorkerFetch(client: RpcClient): typeof fetch {
|
||||
function createEventSource(client: RpcClient): EventSource {
|
||||
return {
|
||||
on: (handler) => client.on<Event>("event", handler),
|
||||
setWorkspace: (workspaceID) => {
|
||||
void client.call("setWorkspace", { workspaceID })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,18 +113,20 @@ export const TuiThreadCommand = cmd({
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||
// Resolve relative --project paths from PWD, then use the real cwd after
|
||||
// chdir so the thread and worker share the same directory key.
|
||||
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
|
||||
const cwd = args.project
|
||||
const next = args.project
|
||||
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
|
||||
: root
|
||||
: Filesystem.resolve(process.cwd())
|
||||
const file = await target()
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
process.chdir(next)
|
||||
} catch {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
UI.error("Failed to change directory to " + next)
|
||||
return
|
||||
}
|
||||
const cwd = Filesystem.resolve(process.cwd())
|
||||
|
||||
const worker = new Worker(file, {
|
||||
env: Object.fromEntries(
|
||||
@@ -208,7 +213,6 @@ export const TuiThreadCommand = cmd({
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
onExit: stop,
|
||||
})
|
||||
} finally {
|
||||
await stop()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -23,7 +23,7 @@ export namespace Keybind {
|
||||
*/
|
||||
export function fromParsedKey(key: ParsedKey, leader = false): Info {
|
||||
return {
|
||||
name: key.name,
|
||||
name: key.name === " " ? "space" : key.name,
|
||||
ctrl: key.ctrl,
|
||||
meta: key.meta,
|
||||
shift: key.shift,
|
||||
|
||||
157
packages/opencode/test/cli/tui/thread.test.ts
Normal file
157
packages/opencode/test/cli/tui/thread.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, mock, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
|
||||
const stop = new Error("stop")
|
||||
const seen = {
|
||||
tui: [] as string[],
|
||||
inst: [] as string[],
|
||||
}
|
||||
|
||||
mock.module("../../../src/cli/cmd/tui/app", () => ({
|
||||
tui: async (input: { directory: string }) => {
|
||||
seen.tui.push(input.directory)
|
||||
throw stop
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/util/rpc", () => ({
|
||||
Rpc: {
|
||||
client: () => ({
|
||||
call: async () => ({ url: "http://127.0.0.1" }),
|
||||
on: () => {},
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/cli/ui", () => ({
|
||||
UI: {
|
||||
error: () => {},
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/util/log", () => ({
|
||||
Log: {
|
||||
init: async () => {},
|
||||
create: () => ({
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
time: () => ({ stop: () => {} }),
|
||||
}),
|
||||
Default: {
|
||||
error: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/util/timeout", () => ({
|
||||
withTimeout: <T>(input: Promise<T>) => input,
|
||||
}))
|
||||
|
||||
mock.module("@/cli/network", () => ({
|
||||
withNetworkOptions: <T>(input: T) => input,
|
||||
resolveNetworkOptions: async () => ({
|
||||
mdns: false,
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("../../../src/cli/cmd/tui/win32", () => ({
|
||||
win32DisableProcessedInput: () => {},
|
||||
win32InstallCtrlCGuard: () => undefined,
|
||||
}))
|
||||
|
||||
mock.module("@/config/tui", () => ({
|
||||
TuiConfig: {
|
||||
get: () => ({}),
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/project/instance", () => ({
|
||||
Instance: {
|
||||
provide: async (input: { directory: string; fn: () => Promise<unknown> | unknown }) => {
|
||||
seen.inst.push(input.directory)
|
||||
return input.fn()
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe("tui thread", () => {
|
||||
async function call(project?: string) {
|
||||
const { TuiThreadCommand } = await import("../../../src/cli/cmd/tui/thread")
|
||||
const args: Parameters<NonNullable<typeof TuiThreadCommand.handler>>[0] = {
|
||||
_: [],
|
||||
$0: "opencode",
|
||||
project,
|
||||
prompt: "hi",
|
||||
model: undefined,
|
||||
agent: undefined,
|
||||
session: undefined,
|
||||
continue: false,
|
||||
fork: false,
|
||||
port: 0,
|
||||
hostname: "127.0.0.1",
|
||||
mdns: false,
|
||||
"mdns-domain": "opencode.local",
|
||||
mdnsDomain: "opencode.local",
|
||||
cors: [],
|
||||
}
|
||||
return TuiThreadCommand.handler(args)
|
||||
}
|
||||
|
||||
async function check(project?: string) {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const cwd = process.cwd()
|
||||
const pwd = process.env.PWD
|
||||
const worker = globalThis.Worker
|
||||
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY")
|
||||
const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
|
||||
const type = process.platform === "win32" ? "junction" : "dir"
|
||||
seen.tui.length = 0
|
||||
seen.inst.length = 0
|
||||
await fs.symlink(tmp.path, link, type)
|
||||
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
})
|
||||
globalThis.Worker = class extends EventTarget {
|
||||
onerror = null
|
||||
onmessage = null
|
||||
onmessageerror = null
|
||||
postMessage() {}
|
||||
terminate() {}
|
||||
} as unknown as typeof Worker
|
||||
|
||||
try {
|
||||
process.chdir(tmp.path)
|
||||
process.env.PWD = link
|
||||
await expect(call(project)).rejects.toBe(stop)
|
||||
expect(seen.inst[0]).toBe(tmp.path)
|
||||
expect(seen.tui[0]).toBe(tmp.path)
|
||||
} finally {
|
||||
process.chdir(cwd)
|
||||
if (pwd === undefined) delete process.env.PWD
|
||||
else process.env.PWD = pwd
|
||||
if (tty) Object.defineProperty(process.stdin, "isTTY", tty)
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY
|
||||
globalThis.Worker = worker
|
||||
await fs.rm(link, { recursive: true, force: true }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
test("uses the real cwd when PWD points at a symlink", async () => {
|
||||
await check()
|
||||
})
|
||||
|
||||
test("uses the real cwd after resolving a relative project from PWD", async () => {
|
||||
await check(".")
|
||||
})
|
||||
})
|
||||
@@ -36,4 +36,19 @@ describe("file.ripgrep", () => {
|
||||
expect(hasVisible).toBe(true)
|
||||
expect(hasHidden).toBe(false)
|
||||
})
|
||||
|
||||
test("search returns empty when nothing matches", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "match.ts"), "const value = 'other'\n")
|
||||
},
|
||||
})
|
||||
|
||||
const hits = await Ripgrep.search({
|
||||
cwd: tmp.path,
|
||||
pattern: "needle",
|
||||
})
|
||||
|
||||
expect(hits).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
47
packages/opencode/test/installation/installation.test.ts
Normal file
47
packages/opencode/test/installation/installation.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Installation } from "../../src/installation"
|
||||
|
||||
const fetch0 = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = fetch0
|
||||
})
|
||||
|
||||
describe("installation", () => {
|
||||
test("reads release version from GitHub releases", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ tag_name: "v1.2.3" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
expect(await Installation.latest("unknown")).toBe("1.2.3")
|
||||
})
|
||||
|
||||
test("reads scoop manifest versions", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ version: "2.3.4" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})) as unknown as typeof fetch
|
||||
|
||||
expect(await Installation.latest("scoop")).toBe("2.3.4")
|
||||
})
|
||||
|
||||
test("reads chocolatey feed versions", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
d: {
|
||||
results: [{ Version: "3.4.5" }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
)) as unknown as typeof fetch
|
||||
|
||||
expect(await Installation.latest("choco")).toBe("3.4.5")
|
||||
})
|
||||
})
|
||||
@@ -198,6 +198,30 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
|
||||
})
|
||||
})
|
||||
|
||||
test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
init: async () => {
|
||||
Env.set("GITLAB_TOKEN", "test-token")
|
||||
},
|
||||
fn: async () => {
|
||||
const providers = await Provider.list()
|
||||
expect(providers["gitlab"]).toBeDefined()
|
||||
expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("GitLab Duo: supports feature flags configuration", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -2002,6 +2002,35 @@ describe("ProviderTransform.variants", () => {
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("gpt-5.3-codex includes xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5.3-codex",
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: "gpt-5.3-codex",
|
||||
url: "https://api.githubcopilot.com",
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
|
||||
test("gpt-5.4 includes xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "gpt-5.4",
|
||||
release_date: "2026-03-05",
|
||||
providerID: "github-copilot",
|
||||
api: {
|
||||
id: "gpt-5.4",
|
||||
url: "https://api.githubcopilot.com",
|
||||
npm: "@ai-sdk/github-copilot",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("@ai-sdk/cerebras", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Process } from "../../src/util/process"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
function node(script: string) {
|
||||
return [process.execPath, "-e", script]
|
||||
@@ -56,4 +57,21 @@ describe("util.process", () => {
|
||||
expect(out.code).not.toBe(0)
|
||||
expect(Date.now() - started).toBeLessThan(1000)
|
||||
}, 3000)
|
||||
|
||||
test("uses cwd when spawning commands", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const out = await Process.run(node("process.stdout.write(process.cwd())"), {
|
||||
cwd: tmp.path,
|
||||
})
|
||||
expect(out.stdout.toString()).toBe(tmp.path)
|
||||
})
|
||||
|
||||
test("merges environment overrides", async () => {
|
||||
const out = await Process.run(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
|
||||
env: {
|
||||
OPENCODE_TEST: "set",
|
||||
},
|
||||
})
|
||||
expect(out.stdout.toString()).toBe("set")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { type Config } from "./gen/client/types.gen.js"
|
||||
import { OpencodeClient } from "./gen/sdk.gen.js"
|
||||
export { type Config as OpencodeClientConfig, OpencodeClient }
|
||||
|
||||
export function createOpencodeClient(config?: Config & { directory?: string }) {
|
||||
export function createOpencodeClient(config?: Config & { directory?: string; experimental_workspaceID?: string }) {
|
||||
if (!config?.fetch) {
|
||||
const customFetch: any = (req: any) => {
|
||||
// @ts-ignore
|
||||
@@ -27,6 +27,13 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
if (config?.experimental_workspaceID) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
"x-opencode-workspace": config.experimental_workspaceID,
|
||||
}
|
||||
}
|
||||
|
||||
const client = createClient(config)
|
||||
return new OpencodeClient({ client })
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,28 +2,89 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--surface-inset-base);
|
||||
border: 1px solid var(--border-weaker-base);
|
||||
transition: background-color 0.15s ease;
|
||||
position: relative;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 6px 12px;
|
||||
overflow: clip;
|
||||
padding: 10px 12px 10px 10px;
|
||||
|
||||
&[data-variant="error"] {
|
||||
background-color: var(--surface-critical-weak);
|
||||
border: 1px solid var(--border-critical-base);
|
||||
color: rgba(218, 51, 25, 0.6);
|
||||
&:has([data-slot="card-title"]) {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* text-12-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
|
||||
&[data-component="icon"] {
|
||||
color: var(--icon-critical-active);
|
||||
}
|
||||
--card-gap: 8px;
|
||||
--card-icon: 16px;
|
||||
--card-indent: calc(var(--card-icon) + var(--card-gap));
|
||||
|
||||
--card-accent: var(--icon-active);
|
||||
|
||||
&[data-icon="none"] {
|
||||
--card-indent: 0px;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--card-accent);
|
||||
}
|
||||
|
||||
:where([data-card="title"], [data-slot="card-title"]) {
|
||||
color: var(--text-strong);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--card-gap);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title"]) [data-component="icon"] {
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title-icon"]) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--card-icon);
|
||||
height: var(--card-icon);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
:where([data-slot="card-title-icon"][data-placeholder]) [data-component="icon"] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
:where([data-slot="card-title-icon"])
|
||||
[data-slot="icon-svg"]
|
||||
:is(path, line, polyline, polygon, rect, circle, ellipse)[stroke] {
|
||||
stroke-width: 1.5px !important;
|
||||
}
|
||||
|
||||
:where([data-card="description"], [data-slot="card-description"]) {
|
||||
color: var(--text-base);
|
||||
padding-left: var(--card-indent);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:where([data-card="actions"], [data-slot="card-actions"]) {
|
||||
padding-left: var(--card-indent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-nocheck
|
||||
import { Card } from "./card"
|
||||
import { Card, CardActions, CardDescription, CardTitle } from "./card"
|
||||
import { Button } from "./button"
|
||||
|
||||
const docs = `### Overview
|
||||
@@ -49,15 +49,13 @@ export default {
|
||||
render: (props: { variant?: "normal" | "error" | "warning" | "success" | "info" }) => {
|
||||
return (
|
||||
<Card variant={props.variant}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 500 }}>Card title</div>
|
||||
<div style={{ color: "var(--text-weak)", fontSize: "13px" }}>Small supporting text.</div>
|
||||
</div>
|
||||
<Button size="small" variant="ghost">
|
||||
<CardTitle>Card title</CardTitle>
|
||||
<CardDescription>Small supporting text.</CardDescription>
|
||||
<CardActions>
|
||||
<Button size="small" variant="secondary">
|
||||
Action
|
||||
</Button>
|
||||
</div>
|
||||
</CardActions>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,22 +1,135 @@
|
||||
import { type ComponentProps, splitProps } from "solid-js"
|
||||
import { createContext, type ComponentProps, splitProps, useContext } from "solid-js"
|
||||
import { Icon, type IconProps } from "./icon"
|
||||
|
||||
export interface CardProps extends ComponentProps<"div"> {
|
||||
variant?: "normal" | "error" | "warning" | "success" | "info"
|
||||
|
||||
/**
|
||||
* Optional card icon used by `CardTitle`.
|
||||
*
|
||||
* - `undefined`: picks a default icon based on `variant` (error/warning/info)
|
||||
* - `false`/`null`: disables the icon
|
||||
* - `Icon` name: forces a specific icon
|
||||
*/
|
||||
icon?: IconProps["name"] | false | null
|
||||
}
|
||||
|
||||
type Ctx = {
|
||||
variant: NonNullable<CardProps["variant"]>
|
||||
mode: "none" | "set" | "placeholder"
|
||||
icon?: IconProps["name"]
|
||||
}
|
||||
|
||||
const Ctx = createContext<Ctx>()
|
||||
|
||||
function pick(variant: NonNullable<CardProps["variant"]>) {
|
||||
if (variant === "error") return "circle-ban-sign" as const
|
||||
if (variant === "warning") return "warning" as const
|
||||
if (variant === "success") return "circle-check" as const
|
||||
if (variant === "info") return "help" as const
|
||||
return
|
||||
}
|
||||
|
||||
function mix(style: ComponentProps<"div">["style"], value?: string) {
|
||||
if (!value) return style
|
||||
if (!style) return { "--card-accent": value }
|
||||
if (typeof style === "string") return `${style};--card-accent:${value};`
|
||||
return { ...(style as Record<string, string | number>), "--card-accent": value }
|
||||
}
|
||||
|
||||
export function Card(props: CardProps) {
|
||||
const [split, rest] = splitProps(props, ["variant", "class", "classList"])
|
||||
const [split, rest] = splitProps(props, ["variant", "icon", "style", "class", "classList"])
|
||||
const variant = () => split.variant || "normal"
|
||||
const accent = () => {
|
||||
const v = variant()
|
||||
if (v === "error") return "var(--icon-critical-base)"
|
||||
if (v === "warning") return "var(--icon-warning-active)"
|
||||
if (v === "success") return "var(--icon-success-active)"
|
||||
if (v === "info") return "var(--icon-info-active)"
|
||||
return
|
||||
}
|
||||
const mode = () => {
|
||||
if (split.icon === false || split.icon === null) return "none" as const
|
||||
if (typeof split.icon === "string") return "set" as const
|
||||
return pick(variant()) ? ("set" as const) : ("placeholder" as const)
|
||||
}
|
||||
const icon = () => {
|
||||
if (split.icon === false || split.icon === null) return
|
||||
if (typeof split.icon === "string") return split.icon
|
||||
return pick(variant())
|
||||
}
|
||||
return (
|
||||
<Ctx.Provider value={{ variant: variant(), mode: mode(), icon: icon() }}>
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={variant()}
|
||||
data-icon={mode()}
|
||||
style={mix(split.style, accent())}
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
</Ctx.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardTitle(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
const ctx = useContext(Ctx)
|
||||
const show = () => ctx?.mode !== "none"
|
||||
const name = () => ctx?.icon ?? ("dash" as const)
|
||||
const placeholder = () => !ctx?.icon
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-component="card"
|
||||
data-variant={split.variant || "normal"}
|
||||
data-slot="card-title"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
{show() ? (
|
||||
<span data-slot="card-title-icon" data-placeholder={placeholder() || undefined}>
|
||||
<Icon name={name()} size="small" />
|
||||
</span>
|
||||
) : null}
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-slot="card-description"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardActions(props: ComponentProps<"div">) {
|
||||
const [split, rest] = splitProps(props, ["class", "classList", "children"])
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
data-slot="card-actions"
|
||||
classList={{
|
||||
...(split.classList ?? {}),
|
||||
[split.class ?? ""]: !!split.class,
|
||||
}}
|
||||
>
|
||||
{split.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,18 +8,14 @@
|
||||
border-radius: var(--radius-md);
|
||||
overflow: visible;
|
||||
|
||||
&.tool-collapsible [data-slot="collapsible-trigger"] {
|
||||
height: 37px;
|
||||
}
|
||||
|
||||
&.tool-collapsible [data-slot="basic-tool-content-inner"] {
|
||||
padding-top: 0;
|
||||
&.tool-collapsible {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="collapsible-trigger"] {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
height: 36px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
@@ -27,17 +23,6 @@
|
||||
user-select: none;
|
||||
color: var(--text-base);
|
||||
|
||||
> [data-component="tool-trigger"][data-arrow] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
|
||||
[data-slot="basic-tool-tool-trigger-content"] {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="collapsible-arrow"] {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
@@ -65,6 +50,9 @@
|
||||
line-height: var(--line-height-large); /* 166.667% */
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
|
||||
/* &:hover { */
|
||||
/* background-color: var(--surface-base); */
|
||||
/* } */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
@@ -94,16 +82,16 @@
|
||||
}
|
||||
|
||||
[data-slot="collapsible-content"] {
|
||||
overflow: clip;
|
||||
overflow: hidden;
|
||||
/* animation: slideUp 250ms ease-out; */
|
||||
|
||||
&[data-expanded] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* JS-animated content: overflow managed by animate() */
|
||||
&[data-spring-content] {
|
||||
overflow: clip;
|
||||
}
|
||||
/* &[data-expanded] { */
|
||||
/* animation: slideDown 250ms ease-out; */
|
||||
/* } */
|
||||
}
|
||||
|
||||
&[data-variant="ghost"] {
|
||||
@@ -115,6 +103,9 @@
|
||||
border: none;
|
||||
padding: 0;
|
||||
|
||||
/* &:hover { */
|
||||
/* color: var(--text-strong); */
|
||||
/* } */
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
@@ -131,3 +122,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
height: var(--kb-collapsible-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +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 { useI18n } from "../context/i18n"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
import { ToolCall } from "./basic-tool"
|
||||
import { ToolStatusTitle } from "./tool-status-title"
|
||||
import { AnimatedCountList } from "./tool-count-summary"
|
||||
import { RollingResults } from "./rolling-results"
|
||||
import { 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.pending && props.open}
|
||||
showArrow={!props.pending}
|
||||
onOpenChange={(v) => {
|
||||
if (!props.pending) props.onOpenChange(v)
|
||||
}}
|
||||
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 wiped = new Set<string>()
|
||||
const [mounted, setMounted] = createSignal(false)
|
||||
onMount(() => setMounted(true))
|
||||
const reduce = prefersReducedMotion
|
||||
const show = () => mounted() && props.pending
|
||||
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
|
||||
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
|
||||
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
|
||||
export interface GrowBoxProps {
|
||||
children: JSX.Element
|
||||
/** Enable animation. When false, content shows immediately at full height. */
|
||||
animate?: boolean
|
||||
/** Animate height from 0 to content height. Default: true. */
|
||||
grow?: boolean
|
||||
/** Keep watching body size and animate subsequent height changes. Default: false. */
|
||||
watch?: boolean
|
||||
/** Fade in body content (opacity + blur). Default: true. */
|
||||
fade?: boolean
|
||||
/** Top padding in px on the body wrapper. Default: 0. */
|
||||
gap?: number
|
||||
/** Reset to height:auto after grow completes, or stay at fixed px. Default: true. */
|
||||
autoHeight?: boolean
|
||||
/** Controlled visibility for animating open/close without unmounting children. */
|
||||
open?: boolean
|
||||
/** Animate controlled open/close changes after mount. Default: true. */
|
||||
animateToggle?: boolean
|
||||
/** data-slot attribute on the root div. */
|
||||
slot?: string
|
||||
/** CSS class on the root div. */
|
||||
class?: string
|
||||
/** Override mount and resize spring config. Default: GROW_SPRING. */
|
||||
spring?: SpringConfig
|
||||
/** Override controlled open/close spring config. Default: spring. */
|
||||
toggleSpring?: SpringConfig
|
||||
/** Show a temporary bottom edge fade while height animation is running. */
|
||||
edge?: boolean
|
||||
/** Edge fade height in px. Default: 20. */
|
||||
edgeHeight?: number
|
||||
/** Edge fade opacity (0-1). Default: 1. */
|
||||
edgeOpacity?: number
|
||||
/** Delay before edge fades out after height settles. Default: 320. */
|
||||
edgeIdle?: number
|
||||
/** Edge fade-out duration in seconds. Default: 0.24. */
|
||||
edgeFade?: number
|
||||
/** Edge fade-in duration in seconds. Default: 0.2. */
|
||||
edgeRise?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps children in a container that animates from zero height on mount.
|
||||
*
|
||||
* Includes a ResizeObserver so content changes after mount are also spring-animated.
|
||||
* Used for timeline turns, assistant part groups, and user messages.
|
||||
*/
|
||||
export function GrowBox(props: GrowBoxProps) {
|
||||
const reduce = prefersReducedMotion
|
||||
const spring = () => props.spring ?? GROW_SPRING
|
||||
const toggleSpring = () => props.toggleSpring ?? spring()
|
||||
let mode: "mount" | "toggle" = "mount"
|
||||
let root: HTMLDivElement | undefined
|
||||
let body: HTMLDivElement | undefined
|
||||
let fadeAnim: AnimationPlaybackControls | undefined
|
||||
let edgeRef: HTMLDivElement | undefined
|
||||
let edgeAnim: AnimationPlaybackControls | undefined
|
||||
let edgeTimer: ReturnType<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 (!animated()) {
|
||||
setInstant(open())
|
||||
return
|
||||
}
|
||||
|
||||
if (props.fade !== false) hideBody()
|
||||
hideEdge(true)
|
||||
|
||||
if (!open()) {
|
||||
root.style.height = "0px"
|
||||
root.style.overflow = "clip"
|
||||
} else {
|
||||
if (grow()) {
|
||||
root.style.height = "0px"
|
||||
root.style.overflow = "clip"
|
||||
} else {
|
||||
root.style.height = "auto"
|
||||
root.style.overflow = "visible"
|
||||
}
|
||||
mountFrame = requestAnimationFrame(() => {
|
||||
mountFrame = undefined
|
||||
fadeBodyIn("mount")
|
||||
if (grow()) setHeight("mount")
|
||||
})
|
||||
}
|
||||
if (watch()) {
|
||||
observer = new ResizeObserver(() => {
|
||||
if (!open()) return
|
||||
if (resizeFrame !== undefined) return
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
resizeFrame = undefined
|
||||
setHeight("mount")
|
||||
})
|
||||
})
|
||||
observer.observe(body)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.open,
|
||||
(value) => {
|
||||
if (value === undefined) return
|
||||
if (!root || !body) return
|
||||
if (!animateToggle() || reduce()) {
|
||||
setInstant(value)
|
||||
return
|
||||
}
|
||||
fadeAnim?.stop()
|
||||
if (!value) hideEdge(true)
|
||||
if (!value) {
|
||||
const next = currentHeight()
|
||||
if (Math.abs(next - height.get()) >= 1) {
|
||||
springTarget = next
|
||||
height.jump(next)
|
||||
root.style.height = `${next}px`
|
||||
}
|
||||
if (props.fade !== false) {
|
||||
fadeAnim = animate(body, { opacity: 0, filter: "blur(2px)" }, toggleSpring())
|
||||
}
|
||||
root.style.overflow = "clip"
|
||||
springTarget = 0
|
||||
mode = "toggle"
|
||||
height.set(0)
|
||||
return
|
||||
}
|
||||
fadeBodyIn("toggle")
|
||||
setHeight("toggle")
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (!edgeRef) return
|
||||
edgeRef.style.height = `${edgeHeight()}px`
|
||||
if (!animated() || !open() || edgeHeight() <= 0) {
|
||||
hideEdge(true)
|
||||
return
|
||||
}
|
||||
if (edge()) return
|
||||
hideEdge()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!root || !body) return
|
||||
if (!reduce()) return
|
||||
fadeAnim?.stop()
|
||||
edgeAnim?.stop()
|
||||
setInstant(open())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stopEdgeTimer()
|
||||
if (mountFrame !== undefined) cancelAnimationFrame(mountFrame)
|
||||
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
|
||||
observer?.disconnect()
|
||||
height.destroy()
|
||||
fadeAnim?.stop()
|
||||
edgeAnim?.stop()
|
||||
edgeAnim = undefined
|
||||
edgeOn = false
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={root}
|
||||
data-slot={props.slot}
|
||||
class={props.class}
|
||||
style={{ transform: "translateZ(0)", position: "relative" }}
|
||||
>
|
||||
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
|
||||
{props.children}
|
||||
</div>
|
||||
<div
|
||||
ref={edgeRef}
|
||||
data-slot="grow-box-edge"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
right: "0",
|
||||
bottom: "0",
|
||||
height: `${edgeHeight()}px`,
|
||||
opacity: 0,
|
||||
"pointer-events": "none",
|
||||
background: "linear-gradient(to bottom, transparent 0%, var(--background-stronger) 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -244,6 +244,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
event.stopPropagation()
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
split.onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
ol {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-left: 0;
|
||||
padding-left: 1.5rem;
|
||||
list-style-position: outside;
|
||||
}
|
||||
@@ -70,6 +71,7 @@
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
padding-left: 2.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
@@ -98,6 +100,10 @@
|
||||
padding-left: 1rem; /* Minimal indent for nesting only */
|
||||
}
|
||||
|
||||
li > ol {
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
/* Blockquotes */
|
||||
blockquote {
|
||||
border-left: 2px solid var(--border-weak-base);
|
||||
|
||||
@@ -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);
|
||||
@@ -291,41 +305,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool-error"] {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-critical-base);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
[data-slot="message-part-tool-error-content"] {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
[data-slot="message-part-tool-error-title"] {
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-on-critical-base);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-slot="message-part-tool-error-message"] {
|
||||
color: var(--text-on-critical-weak);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tool-output"] {
|
||||
white-space: pre;
|
||||
padding: 0;
|
||||
@@ -358,16 +337,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 +413,7 @@
|
||||
[data-component="write-trigger"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
@@ -450,8 +426,7 @@
|
||||
}
|
||||
|
||||
[data-slot="message-part-title"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -483,45 +458,40 @@
|
||||
[data-slot="message-part-title-text"] {
|
||||
text-transform: capitalize;
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-meta-line"],
|
||||
.message-part-meta-line {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: var(--font-weight-regular);
|
||||
|
||||
[data-component="diff-changes"] {
|
||||
flex-shrink: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.message-part-meta-line.soft {
|
||||
[data-slot="message-part-title-filename"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="message-part-title-filename"] {
|
||||
/* No text-transform - preserve original filename casing */
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
[data-slot="message-part-directory-inline"] {
|
||||
color: var(--text-weak);
|
||||
[data-slot="message-part-path"] {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
max-width: min(48vw, 36ch);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
[data-slot="message-part-directory"] {
|
||||
color: var(--text-weak);
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
direction: rtl;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-slot="message-part-filename"] {
|
||||
color: var(--text-strong);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-slot="message-part-actions"] {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="edit-content"] {
|
||||
@@ -612,17 +582,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 +604,7 @@
|
||||
}
|
||||
|
||||
[data-component="context-tool-group-trigger"] {
|
||||
width: 100%;
|
||||
min-height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -652,352 +612,28 @@
|
||||
gap: 0px;
|
||||
cursor: pointer;
|
||||
|
||||
&[data-pending] {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-slot="context-tool-group-title"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Prevent the trigger content from stretching full-width so the arrow sits after the text */
|
||||
[data-slot="basic-tool-tool-trigger-content"]:has([data-component="context-tool-group-trigger"]) {
|
||||
width: auto;
|
||||
flex: 0 1 auto;
|
||||
|
||||
[data-slot="basic-tool-tool-info"] {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-tool-step"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-component="context-tool-expanded-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0 4px 12px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-tool-expanded-row"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
[data-slot="context-tool-expanded-action"] {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-expanded-detail"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-base);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="context-tool-rolling-row"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
padding-left: 12px;
|
||||
|
||||
[data-slot="context-tool-rolling-action"] {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 500;
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="context-tool-rolling-detail"] {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-results"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
[data-slot="shell-rolling-header-clip"] {
|
||||
&:hover [data-slot="shell-rolling-actions"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&[data-clickable="true"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-header"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 37px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-title"] {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-subtitle"] {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: var(--line-height-large);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-actions"] {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.shell-rolling-copy {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-weaker);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-arrow"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--icon-weaker);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-arrow"][data-open="true"] {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-output"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-preview"] {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-component="shell-expanded-output"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-expanded-shell"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-body"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-top"] {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 9px 44px 9px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-command"] {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-prompt"] {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-input"] {
|
||||
min-width: 0;
|
||||
color: var(--text-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-actions"] {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
z-index: 1;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.shell-expanded-copy {
|
||||
border: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: transparent !important;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
[data-slot="collapsible-arrow"] {
|
||||
color: var(--icon-weaker);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--text-base) 8%, transparent) !important;
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--icon-weaker) 40%, transparent) !important;
|
||||
border-radius: var(--radius-sm);
|
||||
[data-component="context-tool-group-list"] {
|
||||
padding: 6px 0 4px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
[data-slot="context-tool-group-item"] {
|
||||
min-width: 0;
|
||||
padding: 6px 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-divider"] {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background: var(--border-weak-base);
|
||||
}
|
||||
|
||||
[data-slot="shell-expanded-pre"] {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"],
|
||||
[data-component="shell-rolling-row"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
[data-slot="shell-rolling-text"] {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-family: var(--font-family-mono);
|
||||
font-feature-settings: var(--font-family-mono--font-feature-settings);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"] [data-slot="shell-rolling-text"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-command"] [data-slot="shell-rolling-prompt"] {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
|
||||
[data-component="shell-rolling-row"] [data-slot="shell-rolling-text"] {
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-component="diagnostics"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1042,7 +678,6 @@
|
||||
[data-component="user-message"] [data-slot="user-message-text"],
|
||||
[data-component="text-part"],
|
||||
[data-component="reasoning-part"],
|
||||
[data-component="tool-error"],
|
||||
[data-component="tool-output"],
|
||||
[data-component="bash-output"],
|
||||
[data-component="edit-content"],
|
||||
@@ -1058,30 +693,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 +1151,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 +1163,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 +1201,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 +1243,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 +1257,4 @@
|
||||
flex-shrink: 0;
|
||||
color: var(--icon-weak);
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
||||
import { attachSpring, motionValue } from "motion"
|
||||
import type { SpringOptions } from "motion"
|
||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
||||
|
||||
type Opt = 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 = prefersReducedMotion
|
||||
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())
|
||||
})
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { followValue } from "motion"
|
||||
import type { MotionValue } from "motion"
|
||||
|
||||
export { animate, springValue } from "motion"
|
||||
export type { AnimationPlaybackControls } from "motion"
|
||||
|
||||
/**
|
||||
* Like `springValue` but preserves getters on the config object.
|
||||
* `springValue` spreads config at creation, snapshotting getter values.
|
||||
* This passes the config through to `followValue` intact, so getters
|
||||
* on `visualDuration` etc. fire on every `.set()` call.
|
||||
*/
|
||||
export function tunableSpringValue<T extends string | number>(initial: T, config: SpringConfig): MotionValue<T> {
|
||||
return followValue(initial, config as any)
|
||||
}
|
||||
|
||||
let _growDuration = 0.5
|
||||
let _collapsibleDuration = 0.3
|
||||
|
||||
export const GROW_SPRING = {
|
||||
type: "spring" as const,
|
||||
get visualDuration() {
|
||||
return _growDuration
|
||||
},
|
||||
bounce: 0,
|
||||
}
|
||||
|
||||
export const COLLAPSIBLE_SPRING = {
|
||||
type: "spring" as const,
|
||||
get visualDuration() {
|
||||
return _collapsibleDuration
|
||||
},
|
||||
bounce: 0,
|
||||
}
|
||||
|
||||
export const setGrowDuration = (v: number) => {
|
||||
_growDuration = v
|
||||
}
|
||||
export const setCollapsibleDuration = (v: number) => {
|
||||
_collapsibleDuration = v
|
||||
}
|
||||
export const getGrowDuration = () => _growDuration
|
||||
export const getCollapsibleDuration = () => _collapsibleDuration
|
||||
|
||||
export type SpringConfig = { type: "spring"; visualDuration: number; bounce: number }
|
||||
|
||||
export const FAST_SPRING = {
|
||||
type: "spring" as const,
|
||||
visualDuration: 0.35,
|
||||
bounce: 0,
|
||||
}
|
||||
|
||||
export const GLOW_SPRING = {
|
||||
type: "spring" as const,
|
||||
visualDuration: 0.4,
|
||||
bounce: 0.15,
|
||||
}
|
||||
|
||||
export const WIPE_MASK =
|
||||
"linear-gradient(to right, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 45%, rgba(0,0,0,0) 60%, rgba(0,0,0,0) 100%)"
|
||||
|
||||
export const clearMaskStyles = (el: HTMLElement) => {
|
||||
el.style.maskImage = ""
|
||||
el.style.webkitMaskImage = ""
|
||||
el.style.maskSize = ""
|
||||
el.style.webkitMaskSize = ""
|
||||
el.style.maskRepeat = ""
|
||||
el.style.webkitMaskRepeat = ""
|
||||
el.style.maskPosition = ""
|
||||
el.style.webkitMaskPosition = ""
|
||||
}
|
||||
|
||||
export const clearFadeStyles = (el: HTMLElement) => {
|
||||
el.style.opacity = ""
|
||||
el.style.filter = ""
|
||||
el.style.transform = ""
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
[data-component="rolling-results"] {
|
||||
--rolling-results-row-height: 22px;
|
||||
--rolling-results-fixed-height: var(--rolling-results-row-height);
|
||||
--rolling-results-fixed-gap: 0px;
|
||||
--rolling-results-row-gap: 0px;
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
|
||||
[data-slot="rolling-results-viewport"] {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
height: 0;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&[data-overflowing="true"]:not([data-scrollable="true"]) [data-slot="rolling-results-window"] {
|
||||
mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black var(--rolling-results-fade),
|
||||
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
|
||||
transparent 100%
|
||||
);
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
black var(--rolling-results-fade),
|
||||
black calc(100% - calc(var(--rolling-results-fade) * 0.5)),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-fixed"] {
|
||||
min-width: 0;
|
||||
height: var(--rolling-results-fixed-height);
|
||||
min-height: var(--rolling-results-fixed-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-window"] {
|
||||
min-width: 0;
|
||||
margin-top: var(--rolling-results-fixed-gap);
|
||||
height: calc(100% - var(--rolling-results-fixed-height) - var(--rolling-results-fixed-gap));
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
&[data-scrollable="true"] [data-slot="rolling-results-window"] {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-scrollable="true"] [data-slot="rolling-results-track"] {
|
||||
transform: none !important;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-body"] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-track"] {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: var(--rolling-results-row-gap);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-row"],
|
||||
[data-slot="rolling-results-empty"] {
|
||||
min-width: 0;
|
||||
height: var(--rolling-results-row-height);
|
||||
min-height: var(--rolling-results-row-height);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-row"] {
|
||||
color: var(--text-base);
|
||||
}
|
||||
|
||||
[data-slot="rolling-results-empty"] {
|
||||
color: var(--text-weaker);
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
|
||||
import { prefersReducedMotion } from "../hooks/use-reduced-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 reducedMotion = prefersReducedMotion
|
||||
|
||||
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) && !reducedMotion())
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
19
packages/ui/src/components/scroll-view.test.ts
Normal file
19
packages/ui/src/components/scroll-view.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -3,27 +3,23 @@ import type { SessionStatus } from "@opencode-ai/sdk/v2"
|
||||
import { useData } from "../context"
|
||||
import { useFileComponent } from "../context/file"
|
||||
|
||||
import { same } from "@opencode-ai/util/array"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createMemo, createSignal, For, on, onCleanup, ParentProps, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { GrowBox } from "./grow-box"
|
||||
import { AssistantParts, UserMessageDisplay, Part, PART_MAPPING } from "./message-part"
|
||||
import { AssistantParts, Message, Part, PART_MAPPING } from "./message-part"
|
||||
import { Card } from "./card"
|
||||
import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { Collapsible } from "./collapsible"
|
||||
import { DiffChanges } from "./diff-changes"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { TextShimmer } from "./text-shimmer"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
import { list } from "./text-utils"
|
||||
import { SessionRetry } from "./session-retry"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { TextReveal } from "./text-reveal"
|
||||
import { createAutoScroll } from "../hooks"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
@@ -77,12 +73,18 @@ function unwrap(message: string) {
|
||||
return message
|
||||
}
|
||||
|
||||
function same<T>(a: readonly T[], b: readonly T[]) {
|
||||
if (a === b) return true
|
||||
if (a.length !== b.length) return false
|
||||
return a.every((x, i) => x === b[i])
|
||||
}
|
||||
|
||||
function list<T>(value: T[] | undefined | null, fallback: T[]) {
|
||||
if (Array.isArray(value)) return value
|
||||
return fallback
|
||||
}
|
||||
|
||||
const hidden = new Set(["todowrite", "todoread"])
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyDiffs: FileDiff[] = []
|
||||
const idle: SessionStatus = { type: "idle" as const }
|
||||
const handoffHoldMs = 120
|
||||
|
||||
function partState(part: PartType, showReasoningSummaries: boolean) {
|
||||
if (part.type === "tool") {
|
||||
@@ -139,7 +141,6 @@ export function SessionTurn(
|
||||
props: ParentProps<{
|
||||
sessionID: string
|
||||
messageID: string
|
||||
animate?: boolean
|
||||
showReasoningSummaries?: boolean
|
||||
shellToolDefaultOpen?: boolean
|
||||
editToolDefaultOpen?: boolean
|
||||
@@ -158,7 +159,11 @@ export function SessionTurn(
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
|
||||
const emptyMessages: MessageType[] = []
|
||||
const emptyParts: PartType[] = []
|
||||
const emptyAssistant: AssistantMessage[] = []
|
||||
const emptyDiffs: FileDiff[] = []
|
||||
const idle = { type: "idle" as const }
|
||||
|
||||
const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages))
|
||||
|
||||
@@ -186,8 +191,42 @@ export function SessionTurn(
|
||||
return msg
|
||||
})
|
||||
|
||||
const active = createMemo(() => props.active ?? false)
|
||||
const queued = createMemo(() => props.queued ?? false)
|
||||
const pending = createMemo(() => {
|
||||
if (typeof props.active === "boolean" && typeof props.queued === "boolean") return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
return messages.findLast(
|
||||
(item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
})
|
||||
|
||||
const pendingUser = createMemo(() => {
|
||||
const item = pending()
|
||||
if (!item?.parentID) return
|
||||
const messages = allMessages() ?? emptyMessages
|
||||
const result = Binary.search(messages, item.parentID, (m) => m.id)
|
||||
const msg = result.found ? messages[result.index] : messages.find((m) => m.id === item.parentID)
|
||||
if (!msg || msg.role !== "user") return
|
||||
return msg
|
||||
})
|
||||
|
||||
const active = createMemo(() => {
|
||||
if (typeof props.active === "boolean") return props.active
|
||||
const msg = message()
|
||||
const parent = pendingUser()
|
||||
if (!msg || !parent) return false
|
||||
return parent.id === msg.id
|
||||
})
|
||||
|
||||
const queued = createMemo(() => {
|
||||
if (typeof props.queued === "boolean") return props.queued
|
||||
const id = message()?.id
|
||||
if (!id) return false
|
||||
if (!pendingUser()) return false
|
||||
const item = pending()
|
||||
if (!item) return false
|
||||
return id > item.id
|
||||
})
|
||||
|
||||
const parts = createMemo(() => {
|
||||
const msg = message()
|
||||
if (!msg) return emptyParts
|
||||
@@ -250,7 +289,7 @@ export function SessionTurn(
|
||||
const error = createMemo(
|
||||
() => assistantMessages().find((m) => m.error && m.error.name !== "MessageAbortedError")?.error,
|
||||
)
|
||||
const assistantCopyPart = createMemo(() => {
|
||||
const showAssistantCopyPartID = createMemo(() => {
|
||||
const messages = assistantMessages()
|
||||
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
@@ -260,18 +299,13 @@ export function SessionTurn(
|
||||
const parts = list(data.store.part?.[message.id], emptyParts)
|
||||
for (let j = parts.length - 1; j >= 0; j--) {
|
||||
const part = parts[j]
|
||||
if (!part || part.type !== "text") continue
|
||||
const text = part.text?.trim()
|
||||
if (!text) continue
|
||||
return {
|
||||
id: part.id,
|
||||
text,
|
||||
message,
|
||||
}
|
||||
if (!part || part.type !== "text" || !part.text?.trim()) continue
|
||||
return part.id
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
const assistantCopyPartID = createMemo(() => assistantCopyPart()?.id ?? null)
|
||||
const errorText = createMemo(() => {
|
||||
const msg = error()?.data?.message
|
||||
if (typeof msg === "string") return unwrap(msg)
|
||||
@@ -279,14 +313,18 @@ export function SessionTurn(
|
||||
return unwrap(String(msg))
|
||||
})
|
||||
|
||||
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
|
||||
const working = createMemo(() => {
|
||||
if (status().type === "idle") return false
|
||||
if (!message()) return false
|
||||
return active()
|
||||
const status = createMemo(() => {
|
||||
if (props.status !== undefined) return props.status
|
||||
if (typeof props.active === "boolean" && !props.active) return idle
|
||||
return data.store.session_status[props.sessionID] ?? idle
|
||||
})
|
||||
const working = createMemo(() => status().type !== "idle" && active())
|
||||
const showReasoningSummaries = createMemo(() => props.showReasoningSummaries ?? true)
|
||||
const showDiffSummary = createMemo(() => edited() > 0 && !working())
|
||||
|
||||
const assistantCopyPartID = createMemo(() => {
|
||||
if (working()) return null
|
||||
return showAssistantCopyPartID() ?? null
|
||||
})
|
||||
const turnDurationMs = createMemo(() => {
|
||||
const start = message()?.time.created
|
||||
if (typeof start !== "number") return undefined
|
||||
@@ -326,109 +364,13 @@ export function SessionTurn(
|
||||
.filter((text): text is string => !!text)
|
||||
.at(-1),
|
||||
)
|
||||
const thinking = createMemo(() => {
|
||||
const showThinking = createMemo(() => {
|
||||
if (!working() || !!error()) return false
|
||||
if (queued()) return false
|
||||
if (status().type === "retry") return false
|
||||
if (showReasoningSummaries()) return assistantVisible() === 0
|
||||
return true
|
||||
})
|
||||
const hasAssistant = createMemo(() => assistantMessages().length > 0)
|
||||
const animateEnabled = createMemo(() => props.animate !== false)
|
||||
const [live, setLive] = createSignal(false)
|
||||
const thinkingOpen = createMemo(() => thinking() && (live() || !animateEnabled()))
|
||||
const metaOpen = createMemo(() => !working() && !!assistantCopyPart())
|
||||
const duration = createMemo(() => {
|
||||
const ms = turnDurationMs()
|
||||
if (typeof ms !== "number" || ms < 0) return ""
|
||||
|
||||
const total = Math.round(ms / 1000)
|
||||
if (total < 60) return `${total}s`
|
||||
|
||||
const minutes = Math.floor(total / 60)
|
||||
const seconds = total % 60
|
||||
return `${minutes}m ${seconds}s`
|
||||
})
|
||||
const meta = createMemo(() => {
|
||||
const item = assistantCopyPart()
|
||||
if (!item) return ""
|
||||
|
||||
const agent = item.message.agent ? item.message.agent[0]?.toUpperCase() + item.message.agent.slice(1) : ""
|
||||
const model = item.message.modelID
|
||||
? (data.store.provider?.all?.find((provider) => provider.id === item.message.providerID)?.models?.[
|
||||
item.message.modelID
|
||||
]?.name ?? item.message.modelID)
|
||||
: ""
|
||||
return [agent, model, duration()].filter((value) => !!value).join("\u00A0\u00B7\u00A0")
|
||||
})
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const [handoffHold, setHandoffHold] = createSignal(false)
|
||||
const thinkingVisible = createMemo(() => thinkingOpen() || handoffHold())
|
||||
const handoffOpen = createMemo(() => thinkingVisible() || metaOpen())
|
||||
const lane = createMemo(() => hasAssistant() || handoffOpen())
|
||||
|
||||
let liveFrame: number | undefined
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let handoffTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const copyAssistant = async () => {
|
||||
const text = assistantCopyPart()?.text
|
||||
if (!text) return
|
||||
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
|
||||
copiedTimer = setTimeout(() => {
|
||||
copiedTimer = undefined
|
||||
setCopied(false)
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [animateEnabled(), working()] as const,
|
||||
([enabled, isWorking]) => {
|
||||
if (liveFrame !== undefined) {
|
||||
cancelAnimationFrame(liveFrame)
|
||||
liveFrame = undefined
|
||||
}
|
||||
if (!enabled || !isWorking || live()) return
|
||||
liveFrame = requestAnimationFrame(() => {
|
||||
liveFrame = undefined
|
||||
setLive(true)
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [thinkingOpen(), metaOpen()] as const,
|
||||
([thinkingNow, metaNow]) => {
|
||||
if (handoffTimer !== undefined) {
|
||||
clearTimeout(handoffTimer)
|
||||
handoffTimer = undefined
|
||||
}
|
||||
|
||||
if (thinkingNow) {
|
||||
setHandoffHold(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (metaNow) {
|
||||
setHandoffHold(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!handoffHold()) return
|
||||
handoffTimer = setTimeout(() => {
|
||||
handoffTimer = undefined
|
||||
setHandoffHold(false)
|
||||
}, handoffHoldMs)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working,
|
||||
@@ -436,119 +378,6 @@ export function SessionTurn(
|
||||
overflowAnchor: "dynamic",
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (liveFrame !== undefined) cancelAnimationFrame(liveFrame)
|
||||
if (copiedTimer !== undefined) clearTimeout(copiedTimer)
|
||||
if (handoffTimer !== undefined) clearTimeout(handoffTimer)
|
||||
})
|
||||
|
||||
const turnDiffSummary = () => (
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "37px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">{`\u202A${getDirectory(diff.file)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
|
||||
const divider = (label: string) => (
|
||||
<div data-component="compaction-part">
|
||||
<div data-slot="compaction-part-divider">
|
||||
<span data-slot="compaction-part-line" />
|
||||
<span data-slot="compaction-part-label" class="text-12-regular text-text-weak">
|
||||
{label}
|
||||
</span>
|
||||
<span data-slot="compaction-part-line" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-component="session-turn" class={props.classes?.root}>
|
||||
<div
|
||||
@@ -559,120 +388,149 @@ export function SessionTurn(
|
||||
>
|
||||
<div onClick={autoScroll.handleInteraction}>
|
||||
<Show when={message()}>
|
||||
{(msg) => (
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={msg().id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<UserMessageDisplay
|
||||
message={msg()}
|
||||
parts={parts()}
|
||||
interrupted={interrupted()}
|
||||
animate={props.animate}
|
||||
queued={queued()}
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
data-message={message()!.id}
|
||||
data-slot="session-turn-message-container"
|
||||
class={props.classes?.container}
|
||||
>
|
||||
<div data-slot="session-turn-message-content" aria-live="off">
|
||||
<Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={compaction()!} message={message()!} hideDetails />
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={assistantMessages().length > 0}>
|
||||
<div data-slot="session-turn-assistant-content" aria-hidden={working()}>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
turnDurationMs={turnDurationMs()}
|
||||
working={working()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
/>
|
||||
</div>
|
||||
<Show when={compaction()}>
|
||||
{(part) => (
|
||||
<GrowBox animate={props.animate !== false} fade gap={8} class="w-full min-w-0">
|
||||
<div data-slot="session-turn-compaction">
|
||||
<Part part={part()} message={msg()} hideDetails />
|
||||
</div>
|
||||
</GrowBox>
|
||||
)}
|
||||
</Show>
|
||||
<div data-slot="session-turn-assistant-lane" aria-hidden={!lane()}>
|
||||
<Show when={hasAssistant()}>
|
||||
<div
|
||||
data-slot="session-turn-assistant-content"
|
||||
aria-hidden={working()}
|
||||
style={{ contain: "layout paint" }}
|
||||
>
|
||||
<AssistantParts
|
||||
messages={assistantMessages()}
|
||||
showAssistantCopyPartID={assistantCopyPartID()}
|
||||
showTurnDiffSummary={showDiffSummary()}
|
||||
turnDiffSummary={turnDiffSummary}
|
||||
working={working()}
|
||||
animate={live()}
|
||||
showReasoningSummaries={showReasoningSummaries()}
|
||||
shellToolDefaultOpen={props.shellToolDefaultOpen}
|
||||
editToolDefaultOpen={props.editToolDefaultOpen}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={showThinking()}>
|
||||
<div data-slot="session-turn-thinking">
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<Show when={!showReasoningSummaries()}>
|
||||
<TextReveal
|
||||
text={reasoningHeading()}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={700}
|
||||
/>
|
||||
</Show>
|
||||
<GrowBox
|
||||
animate={live()}
|
||||
animateToggle={live()}
|
||||
open={handoffOpen()}
|
||||
fade
|
||||
slot="session-turn-handoff-wrap"
|
||||
>
|
||||
<div data-slot="session-turn-handoff">
|
||||
<div data-slot="session-turn-thinking" data-visible={thinkingVisible() ? "true" : "false"}>
|
||||
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
|
||||
<TextReveal
|
||||
text={!showReasoningSummaries() ? (reasoningHeading() ?? "") : ""}
|
||||
class="session-turn-thinking-heading"
|
||||
travel={25}
|
||||
duration={900}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<Show when={edited() > 0 && !working()}>
|
||||
<div data-slot="session-turn-diffs">
|
||||
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
|
||||
<Collapsible.Trigger>
|
||||
<div data-component="session-turn-diffs-trigger">
|
||||
<div data-slot="session-turn-diffs-title">
|
||||
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
|
||||
<span data-slot="session-turn-diffs-count">
|
||||
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
|
||||
</span>
|
||||
<div data-slot="session-turn-diffs-meta">
|
||||
<DiffChanges changes={diffs()} variant="bars" />
|
||||
<Collapsible.Arrow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={metaOpen()}>
|
||||
<div
|
||||
data-slot="session-turn-meta"
|
||||
data-visible={thinkingVisible() ? "false" : "true"}
|
||||
data-interrupted={interrupted() ? "" : undefined}
|
||||
>
|
||||
<Tooltip
|
||||
value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
||||
placement="top"
|
||||
gutter={4}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Show when={open()}>
|
||||
<div data-component="session-turn-diffs-content">
|
||||
<Accordion
|
||||
multiple
|
||||
style={{ "--sticky-accordion-offset": "40px" }}
|
||||
value={expanded()}
|
||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||
>
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => void copyAssistant()}
|
||||
aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copyResponse")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Show when={meta()}>
|
||||
<span
|
||||
data-slot="session-turn-meta-label"
|
||||
class="text-12-regular text-text-weak cursor-default"
|
||||
>
|
||||
{meta()}
|
||||
</span>
|
||||
</Show>
|
||||
<For each={diffs()}>
|
||||
{(diff) => {
|
||||
const active = createMemo(() => expanded().includes(diff.file))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
active,
|
||||
(value) => {
|
||||
if (!value) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Accordion.Item value={diff.file}>
|
||||
<StickyAccordionHeader>
|
||||
<Accordion.Trigger>
|
||||
<div data-slot="session-turn-diff-trigger">
|
||||
<span data-slot="session-turn-diff-path">
|
||||
<Show when={diff.file.includes("/")}>
|
||||
<span data-slot="session-turn-diff-directory">
|
||||
{`\u202A${getDirectory(diff.file)}\u202C`}
|
||||
</span>
|
||||
</Show>
|
||||
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
|
||||
</span>
|
||||
<div data-slot="session-turn-diff-meta">
|
||||
<span data-slot="session-turn-diff-changes">
|
||||
<DiffChanges changes={diff} />
|
||||
</span>
|
||||
<span data-slot="session-turn-diff-chevron">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Accordion.Trigger>
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content>
|
||||
<Show when={visible()}>
|
||||
<div data-slot="session-turn-diff-view" data-scrollable>
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
before={{ name: diff.file, contents: diff.before }}
|
||||
after={{ name: diff.file, contents: diff.after }}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</GrowBox>
|
||||
</Collapsible.Content>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<GrowBox animate={props.animate !== false} fade gap={0} open={interrupted()} class="w-full min-w-0">
|
||||
{divider(i18n.t("ui.message.interrupted"))}
|
||||
</GrowBox>
|
||||
<SessionRetry status={status()} show={active()} />
|
||||
<GrowBox
|
||||
animate={props.animate !== false}
|
||||
fade
|
||||
gap={0}
|
||||
open={showDiffSummary() && !assistantCopyPartID()}
|
||||
>
|
||||
{turnDiffSummary()}
|
||||
</GrowBox>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<Card variant="error" class="error-card">
|
||||
{errorText()}
|
||||
</Card>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
@@ -1,310 +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 { prefersReducedMotion } 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,
|
||||
hold,
|
||||
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 }) {
|
||||
const i18n = useI18n()
|
||||
const wiped = new Set<string>()
|
||||
const [mounted, setMounted] = createSignal(false)
|
||||
const [userToggled, setUserToggled] = createSignal(false)
|
||||
const [userOpen, setUserOpen] = createSignal(false)
|
||||
onMount(() => setMounted(true))
|
||||
const state = createMemo(() => props.part.state as Record<string, any>)
|
||||
const pending = createMemo(() => busy(props.part.state.status))
|
||||
const autoOpen = hold(pending, 2000)
|
||||
const effectiveOpen = createMemo(() => {
|
||||
if (pending()) return true
|
||||
if (userToggled()) return userOpen()
|
||||
return autoOpen()
|
||||
})
|
||||
const expanded = createMemo(() => !pending() && !autoOpen() && userToggled() && userOpen())
|
||||
const previewOpen = createMemo(() => effectiveOpen() && !expanded())
|
||||
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 reduce = prefersReducedMotion
|
||||
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 = () => {
|
||||
if (pending()) return
|
||||
const el = headerClipRef
|
||||
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
|
||||
const beforeY = el?.getBoundingClientRect().top ?? 0
|
||||
setUserToggled(true)
|
||||
setUserOpen((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={!pending() ? "true" : "false"}
|
||||
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>
|
||||
<Show when={!pending()}>
|
||||
<span data-slot="shell-rolling-actions">
|
||||
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-slot="shell-rolling-preview"
|
||||
style={{
|
||||
opacity: skip() ? (previewOpen() ? 1 : 0) : previewOpacity(),
|
||||
filter: `blur(${skip() ? 0 : previewBlur()}px)`,
|
||||
}}
|
||||
>
|
||||
<RollingResults
|
||||
class="shell-rolling-output"
|
||||
noFadeOnCollapse
|
||||
items={rows()}
|
||||
fixed={fixed()}
|
||||
fixedHeight={22}
|
||||
rows={5}
|
||||
rowHeight={22}
|
||||
rowGap={0}
|
||||
open={previewOpen()}
|
||||
animate={props.animate !== false}
|
||||
getKey={(row) => row.id}
|
||||
render={(row) => {
|
||||
const [textRef, setTextRef] = createSignal<HTMLSpanElement>()
|
||||
useRowWipe({
|
||||
id: () => row.id,
|
||||
text: () => row.text,
|
||||
ref: textRef,
|
||||
seen: wiped,
|
||||
})
|
||||
return (
|
||||
<div data-component="shell-rolling-row">
|
||||
<span ref={setTextRef} data-slot="shell-rolling-text">
|
||||
{row.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ShellExpanded cmd={command()} out={text()} open={expanded()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,23 @@
|
||||
[data-component="shell-submessage"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-width"] {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-component="shell-submessage"] [data-slot="shell-submessage-value"] {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
min-width: 0;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
}}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user