Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
commit
a718f86e0f
46 changed files with 795 additions and 470 deletions
39
bun.lock
39
bun.lock
|
|
@ -26,7 +26,7 @@
|
||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
|
|
@ -110,7 +110,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "2.0.0",
|
"@ai-sdk/anthropic": "2.0.0",
|
||||||
"@ai-sdk/openai": "2.0.2",
|
"@ai-sdk/openai": "2.0.2",
|
||||||
|
|
@ -161,7 +161,7 @@
|
||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|
@ -185,7 +185,7 @@
|
||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|
@ -218,7 +218,7 @@
|
||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
|
@ -248,7 +248,7 @@
|
||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
|
|
@ -277,7 +277,7 @@
|
||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
|
|
@ -293,7 +293,7 @@
|
||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
|
|
@ -409,7 +409,7 @@
|
||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
|
|
@ -429,7 +429,7 @@
|
||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
|
|
@ -440,7 +440,7 @@
|
||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
|
|
@ -475,7 +475,7 @@
|
||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|
@ -483,8 +483,11 @@
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
|
"@solid-primitives/lifecycle": "0.1.2",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
|
"@solid-primitives/page-visibility": "2.1.1",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
|
"@solid-primitives/rootless": "1.5.2",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
|
@ -521,7 +524,7 @@
|
||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
|
|
@ -532,7 +535,7 @@
|
||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|
@ -1834,10 +1837,14 @@
|
||||||
|
|
||||||
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
|
"@solid-primitives/keyed": ["@solid-primitives/keyed@1.5.3", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zNadtyYBhJSOjXtogkGHmRxjGdz9KHc8sGGVAGlUABkE8BED2tbIZoxkwSqzOwde8OcUEH0bb5DLZUWIMvyBSA=="],
|
||||||
|
|
||||||
|
"@solid-primitives/lifecycle": ["@solid-primitives/lifecycle@0.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-+K0T10kZXqorocFj0coIqt8NYm2UqoZfpF3nm2RwrDMZMV+C+SC0Oi3N6Dnq2i7W/n1cHAnfpoV4CBLsW21lJw=="],
|
||||||
|
|
||||||
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
|
"@solid-primitives/map": ["@solid-primitives/map@0.4.13", "", { "dependencies": { "@solid-primitives/trigger": "^1.1.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-B1zyFbsiTQvqPr+cuPCXO72sRuczG9Swncqk5P74NCGw1VE8qa/Ry9GlfI1e/VdeQYHjan+XkbE3rO2GW/qKew=="],
|
||||||
|
|
||||||
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
|
||||||
|
|
||||||
|
"@solid-primitives/page-visibility": ["@solid-primitives/page-visibility@2.1.1", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.1", "@solid-primitives/rootless": "^1.5.1", "@solid-primitives/utils": "^6.3.1" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-CV9BqMqhunf4OOyBkhJCH9f5ivg0ADavdcaBsrqoFvwIk1FoD/blPSHYM4CK8IjS/AEXNcsjlNVc34lMu+2Wdg=="],
|
||||||
|
|
||||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
|
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
|
||||||
|
|
||||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
|
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-4kjoJ06VNvHltPHfzQRBG0bC6R39jao10ffGzrNZ230=",
|
"x86_64-linux": "sha256-c99eE1cKAQHvwJosaFo42U9Hk0Rtp/U5oTTlyiz2Zw4=",
|
||||||
"aarch64-linux": "sha256-6Uio+S2rcyBWbBEeOZb9N1CCKgkbKi68lOIKi3Ws/pQ=",
|
"aarch64-linux": "sha256-LbdssPrf8Bijyp4mRo8QaO/swxwUWSo1g0jLPm2rvUA=",
|
||||||
"aarch64-darwin": "sha256-8ngN5KVN4vhdsk0QJ11BGgSVBrcaEbwSj23c77HBpgs=",
|
"aarch64-darwin": "sha256-0L9y6Zk4l2vAxsM2bENahhtRZY1C3XhdxLgnnYlhkkY=",
|
||||||
"x86_64-darwin": "sha256-v/ueYGb9a0Nymzy+mkO4uQr78DAuJnES1qOT0onFgnQ="
|
"x86_64-darwin": "sha256-0J5sFG/kHHRDcTpdpdPBMJEOHwCRnAUYmbxEHPPLDvU="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,12 +44,14 @@ async function store(page: Page, key: string) {
|
||||||
}, key)
|
}, 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 }) => {
|
await withProject(async ({ directory, gotoSession }) => {
|
||||||
const key = workspacePersistKey(directory, "terminal")
|
const key = workspacePersistKey(directory, "terminal")
|
||||||
const one = `E2E_TERM_ONE_${Date.now()}`
|
const one = `E2E_TERM_ONE_${Date.now()}`
|
||||||
const two = `E2E_TERM_TWO_${Date.now()}`
|
const two = `E2E_TERM_TWO_${Date.now()}`
|
||||||
const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]')
|
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 gotoSession()
|
||||||
await open(page)
|
await open(page)
|
||||||
|
|
@ -61,22 +63,39 @@ test("terminal tab buffers persist across tab switches", async ({ page, withProj
|
||||||
|
|
||||||
await run(page, `echo ${two}`)
|
await run(page, `echo ${two}`)
|
||||||
|
|
||||||
await tabs
|
await first.click()
|
||||||
.filter({ hasText: /Terminal 1/ })
|
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||||
.first()
|
|
||||||
.click()
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
const state = await store(page, key)
|
const state = await store(page, key)
|
||||||
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
const first = state?.all.find((item) => item.titleNumber === 1)?.buffer ?? ""
|
||||||
const second = state?.all.find((item) => item.titleNumber === 2)?.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 },
|
{ 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 })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
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 { useParams } from "@solidjs/router"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
import type { Platform } from "./platform"
|
||||||
|
|
@ -38,6 +38,16 @@ type TerminalCacheEntry = {
|
||||||
|
|
||||||
const caches = new Set<Map<string, 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) {
|
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||||
const key = getWorkspaceTerminalCacheKey(dir)
|
const key = getWorkspaceTerminalCacheKey(dir)
|
||||||
for (const cache of caches) {
|
for (const cache of caches) {
|
||||||
|
|
@ -188,6 +198,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||||
console.error("Failed to update terminal", error)
|
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) {
|
async clone(id: string) {
|
||||||
const index = store.all.findIndex((x) => x.id === id)
|
const index = store.all.findIndex((x) => x.id === id)
|
||||||
const pty = store.all[index]
|
const pty = store.all[index]
|
||||||
|
|
@ -322,12 +344,27 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
|
|
||||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
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 {
|
return {
|
||||||
ready: () => workspace().ready(),
|
ready: () => workspace().ready(),
|
||||||
all: () => workspace().all(),
|
all: () => workspace().all(),
|
||||||
active: () => workspace().active(),
|
active: () => workspace().active(),
|
||||||
new: () => workspace().new(),
|
new: () => workspace().new(),
|
||||||
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
||||||
|
trim: (id: string) => workspace().trim(id),
|
||||||
|
trimAll: () => workspace().trimAll(),
|
||||||
clone: (id: string) => workspace().clone(id),
|
clone: (id: string) => workspace().clone(id),
|
||||||
open: (id: string) => workspace().open(id),
|
open: (id: string) => workspace().open(id),
|
||||||
close: (id: string) => workspace().close(id),
|
close: (id: string) => workspace().close(id),
|
||||||
|
|
|
||||||
|
|
@ -1891,6 +1891,7 @@ export default function Layout(props: ParentProps) {
|
||||||
|
|
||||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean; merged?: boolean }) => {
|
||||||
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
|
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 projectName = createMemo(() => {
|
||||||
const project = panelProps.project
|
const project = panelProps.project
|
||||||
if (!project) return ""
|
if (!project) return ""
|
||||||
|
|
@ -1919,8 +1920,8 @@ export default function Layout(props: ParentProps) {
|
||||||
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
|
"flex flex-col min-h-0 min-w-0 rounded-tl-[12px] px-2": true,
|
||||||
"border border-b-0 border-border-weak-base": !merged(),
|
"border border-b-0 border-border-weak-base": !merged(),
|
||||||
"border-l border-t border-border-weaker-base": merged(),
|
"border-l border-t border-border-weaker-base": merged(),
|
||||||
"bg-background-base": merged(),
|
"bg-background-base": merged() || hover(),
|
||||||
"bg-background-stronger": !merged(),
|
"bg-background-stronger": !merged() && !hover(),
|
||||||
"flex-1 min-w-0": panelProps.mobile,
|
"flex-1 min-w-0": panelProps.mobile,
|
||||||
"max-w-full overflow-hidden": panelProps.mobile,
|
"max-w-full overflow-hidden": panelProps.mobile,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ const ProjectTile = (props: {
|
||||||
modal={!props.sidebarHovering()}
|
modal={!props.sidebarHovering()}
|
||||||
onOpenChange={(value) => {
|
onOpenChange={(value) => {
|
||||||
props.setMenu(value)
|
props.setMenu(value)
|
||||||
|
props.setSuppressHover(value)
|
||||||
if (value) props.setOpen(false)
|
if (value) props.setOpen(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -107,6 +108,12 @@ const ProjectTile = (props: {
|
||||||
!props.selected() && !props.active(),
|
!props.selected() && !props.active(),
|
||||||
"bg-surface-base-hover border border-border-weak-base": !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) => {
|
onMouseEnter={(event: MouseEvent) => {
|
||||||
if (!props.overlay()) return
|
if (!props.overlay()) return
|
||||||
if (props.suppressHover()) return
|
if (props.suppressHover()) return
|
||||||
|
|
|
||||||
|
|
@ -41,216 +41,12 @@ import { createScrollSpy } from "@/pages/session/scroll-spy"
|
||||||
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
|
import { createSessionHistoryWindow, emptyUserMessages } from "@/pages/session/history-window"
|
||||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
|
|
||||||
const emptyUserMessages: UserMessage[] = []
|
|
||||||
|
|
||||||
type SessionHistoryWindowInput = {
|
|
||||||
sessionID: () => string | undefined
|
|
||||||
messagesReady: () => boolean
|
|
||||||
visibleUserMessages: () => UserMessage[]
|
|
||||||
historyMore: () => boolean
|
|
||||||
historyLoading: () => boolean
|
|
||||||
loadMore: (sessionID: string) => Promise<void>
|
|
||||||
userScrolled: () => boolean
|
|
||||||
scroller: () => HTMLDivElement | undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maintains the rendered history window for a session timeline.
|
|
||||||
*
|
|
||||||
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
|
||||||
* small batches while scrolling upward, and prefetches older history near top.
|
|
||||||
*/
|
|
||||||
function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
|
||||||
const turnInit = 10
|
|
||||||
const turnBatch = 8
|
|
||||||
const turnScrollThreshold = 200
|
|
||||||
const turnPrefetchBuffer = 16
|
|
||||||
const prefetchCooldownMs = 400
|
|
||||||
const prefetchNoGrowthLimit = 2
|
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
|
||||||
turnID: undefined as string | undefined,
|
|
||||||
turnStart: 0,
|
|
||||||
prefetchUntil: 0,
|
|
||||||
prefetchNoGrowth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
|
||||||
|
|
||||||
const turnStart = createMemo(() => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
const len = input.visibleUserMessages().length
|
|
||||||
if (!id || len <= 0) return 0
|
|
||||||
if (state.turnID !== id) return initialTurnStart(len)
|
|
||||||
if (state.turnStart <= 0) return 0
|
|
||||||
if (state.turnStart >= len) return initialTurnStart(len)
|
|
||||||
return state.turnStart
|
|
||||||
})
|
|
||||||
|
|
||||||
const setTurnStart = (start: number) => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
const next = start > 0 ? start : 0
|
|
||||||
if (!id) {
|
|
||||||
setState({ turnID: undefined, turnStart: next })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setState({ turnID: id, turnStart: next })
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderedUserMessages = createMemo(
|
|
||||||
() => {
|
|
||||||
const msgs = input.visibleUserMessages()
|
|
||||||
const start = turnStart()
|
|
||||||
if (start <= 0) return msgs
|
|
||||||
return msgs.slice(start)
|
|
||||||
},
|
|
||||||
emptyUserMessages,
|
|
||||||
{
|
|
||||||
equals: same,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const preserveScroll = (fn: () => void) => {
|
|
||||||
const el = input.scroller()
|
|
||||||
if (!el) {
|
|
||||||
fn()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const beforeTop = el.scrollTop
|
|
||||||
fn()
|
|
||||||
void el.scrollHeight
|
|
||||||
el.scrollTop = beforeTop
|
|
||||||
}
|
|
||||||
|
|
||||||
const backfillTurns = () => {
|
|
||||||
const start = turnStart()
|
|
||||||
if (start <= 0) return
|
|
||||||
|
|
||||||
const next = start - turnBatch
|
|
||||||
const nextStart = next > 0 ? next : 0
|
|
||||||
|
|
||||||
preserveScroll(() => setTurnStart(nextStart))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Button path: reveal all cached turns, fetch older history, reveal one batch. */
|
|
||||||
const loadAndReveal = async () => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
if (!id) return
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
const beforeVisible = input.visibleUserMessages().length
|
|
||||||
|
|
||||||
if (start > 0) setTurnStart(0)
|
|
||||||
|
|
||||||
if (!input.historyMore() || input.historyLoading()) return
|
|
||||||
|
|
||||||
await input.loadMore(id)
|
|
||||||
if (input.sessionID() !== id) return
|
|
||||||
|
|
||||||
const afterVisible = input.visibleUserMessages().length
|
|
||||||
const growth = afterVisible - beforeVisible
|
|
||||||
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
|
||||||
if (growth <= 0) return
|
|
||||||
if (turnStart() !== 0) return
|
|
||||||
|
|
||||||
const target = Math.min(afterVisible, Math.max(beforeVisible, renderedUserMessages().length) + turnBatch)
|
|
||||||
const nextStart = Math.max(0, afterVisible - target)
|
|
||||||
preserveScroll(() => setTurnStart(nextStart))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scroll/prefetch path: fetch older history from server. */
|
|
||||||
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
|
||||||
const id = input.sessionID()
|
|
||||||
if (!id) return
|
|
||||||
if (!input.historyMore() || input.historyLoading()) return
|
|
||||||
|
|
||||||
if (opts?.prefetch) {
|
|
||||||
const now = Date.now()
|
|
||||||
if (state.prefetchUntil > now) return
|
|
||||||
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
|
||||||
setState("prefetchUntil", now + prefetchCooldownMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
const beforeVisible = input.visibleUserMessages().length
|
|
||||||
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
|
||||||
|
|
||||||
await input.loadMore(id)
|
|
||||||
if (input.sessionID() !== id) return
|
|
||||||
|
|
||||||
const afterVisible = input.visibleUserMessages().length
|
|
||||||
const growth = afterVisible - beforeVisible
|
|
||||||
|
|
||||||
if (opts?.prefetch) {
|
|
||||||
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
|
||||||
} else if (growth > 0 && state.prefetchNoGrowth) {
|
|
||||||
setState("prefetchNoGrowth", 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (growth <= 0) return
|
|
||||||
if (turnStart() !== start) return
|
|
||||||
|
|
||||||
const reveal = !opts?.prefetch
|
|
||||||
const currentRendered = renderedUserMessages().length
|
|
||||||
const base = Math.max(beforeRendered, currentRendered)
|
|
||||||
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base
|
|
||||||
const nextStart = Math.max(0, afterVisible - target)
|
|
||||||
preserveScroll(() => setTurnStart(nextStart))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onScrollerScroll = () => {
|
|
||||||
if (!input.userScrolled()) return
|
|
||||||
const el = input.scroller()
|
|
||||||
if (!el) return
|
|
||||||
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
|
||||||
|
|
||||||
const start = turnStart()
|
|
||||||
if (start > 0) {
|
|
||||||
if (start <= turnPrefetchBuffer) {
|
|
||||||
void fetchOlderMessages({ prefetch: true })
|
|
||||||
}
|
|
||||||
backfillTurns()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void fetchOlderMessages()
|
|
||||||
}
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
input.sessionID,
|
|
||||||
() => {
|
|
||||||
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
createEffect(
|
|
||||||
on(
|
|
||||||
() => [input.sessionID(), input.messagesReady()] as const,
|
|
||||||
([id, ready]) => {
|
|
||||||
if (!id || !ready) return
|
|
||||||
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
|
||||||
},
|
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
turnStart,
|
|
||||||
setTurnStart,
|
|
||||||
renderedUserMessages,
|
|
||||||
loadAndReveal,
|
|
||||||
onScrollerScroll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const globalSync = useGlobalSync()
|
const globalSync = useGlobalSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
@ -1090,6 +886,7 @@ export default function Page() {
|
||||||
|
|
||||||
let scrollStateFrame: number | undefined
|
let scrollStateFrame: number | undefined
|
||||||
let scrollStateTarget: HTMLDivElement | undefined
|
let scrollStateTarget: HTMLDivElement | undefined
|
||||||
|
let historyFillFrame: number | undefined
|
||||||
const scrollSpy = createScrollSpy({
|
const scrollSpy = createScrollSpy({
|
||||||
onActive: (id) => {
|
onActive: (id) => {
|
||||||
if (id === store.messageId) return
|
if (id === store.messageId) return
|
||||||
|
|
@ -1159,7 +956,9 @@ export default function Page() {
|
||||||
scroller = el
|
scroller = el
|
||||||
autoScroll.scrollRef(el)
|
autoScroll.scrollRef(el)
|
||||||
scrollSpy.setContainer(el)
|
scrollSpy.setContainer(el)
|
||||||
if (el) scheduleScrollState(el)
|
if (!el) return
|
||||||
|
scheduleScrollState(el)
|
||||||
|
scheduleHistoryFill()
|
||||||
}
|
}
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
|
|
@ -1168,6 +967,7 @@ export default function Page() {
|
||||||
const el = scroller
|
const el = scroller
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
scrollSpy.markDirty()
|
scrollSpy.markDirty()
|
||||||
|
scheduleHistoryFill()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1182,6 +982,45 @@ export default function Page() {
|
||||||
scroller: () => scroller,
|
scroller: () => scroller,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const scheduleHistoryFill = () => {
|
||||||
|
if (historyFillFrame !== undefined) return
|
||||||
|
|
||||||
|
historyFillFrame = requestAnimationFrame(() => {
|
||||||
|
historyFillFrame = undefined
|
||||||
|
|
||||||
|
if (!params.id || !messagesReady()) return
|
||||||
|
if (autoScroll.userScrolled() || historyLoading()) return
|
||||||
|
|
||||||
|
const el = scroller
|
||||||
|
if (!el) return
|
||||||
|
if (el.scrollHeight > el.clientHeight + 1) return
|
||||||
|
if (historyWindow.turnStart() <= 0 && !historyMore()) return
|
||||||
|
|
||||||
|
void historyWindow.loadAndReveal()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
params.id,
|
||||||
|
messagesReady(),
|
||||||
|
historyWindow.turnStart(),
|
||||||
|
historyMore(),
|
||||||
|
historyLoading(),
|
||||||
|
autoScroll.userScrolled(),
|
||||||
|
visibleUserMessages().length,
|
||||||
|
] as const,
|
||||||
|
([id, ready, start, more, loading, scrolled]) => {
|
||||||
|
if (!id || !ready || loading || scrolled) return
|
||||||
|
if (start <= 0 && !more) return
|
||||||
|
scheduleHistoryFill()
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
createResizeObserver(
|
createResizeObserver(
|
||||||
() => promptDock,
|
() => promptDock,
|
||||||
({ height }) => {
|
({ height }) => {
|
||||||
|
|
@ -1199,6 +1038,7 @@ export default function Page() {
|
||||||
|
|
||||||
if (el) scheduleScrollState(el)
|
if (el) scheduleScrollState(el)
|
||||||
scrollSpy.markDirty()
|
scrollSpy.markDirty()
|
||||||
|
scheduleHistoryFill()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1228,6 +1068,7 @@ export default function Page() {
|
||||||
document.removeEventListener("keydown", handleKeyDown)
|
document.removeEventListener("keydown", handleKeyDown)
|
||||||
scrollSpy.destroy()
|
scrollSpy.destroy()
|
||||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||||
|
if (historyFillFrame !== undefined) cancelAnimationFrame(historyFillFrame)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
35
packages/app/src/pages/session/history-window.test.ts
Normal file
35
packages/app/src/pages/session/history-window.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { historyLoadMode, historyRevealTop } from "./history-window"
|
||||||
|
|
||||||
|
describe("historyLoadMode", () => {
|
||||||
|
test("reveals cached turns before fetching", () => {
|
||||||
|
expect(historyLoadMode({ start: 10, more: true, loading: false })).toBe("reveal")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fetches older history when cache is already revealed", () => {
|
||||||
|
expect(historyLoadMode({ start: 0, more: true, loading: false })).toBe("fetch")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does nothing while history is unavailable or loading", () => {
|
||||||
|
expect(historyLoadMode({ start: 0, more: false, loading: false })).toBe("noop")
|
||||||
|
expect(historyLoadMode({ start: 0, more: true, loading: true })).toBe("noop")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("historyRevealTop", () => {
|
||||||
|
test("pins the viewport to the top when older turns were revealed there", () => {
|
||||||
|
expect(historyRevealTop({ top: -400, height: 1000, gap: 0, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
|
||||||
|
-1400,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the latest turns pinned when the viewport was underfilled", () => {
|
||||||
|
expect(historyRevealTop({ top: 0, height: 200, gap: -400, max: -400 }, { clientHeight: 600, height: 2000 })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps the current anchor when the user was not at the top", () => {
|
||||||
|
expect(historyRevealTop({ top: -200, height: 1000, gap: 200, max: 400 }, { clientHeight: 600, height: 2000 })).toBe(
|
||||||
|
-200,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
273
packages/app/src/pages/session/history-window.ts
Normal file
273
packages/app/src/pages/session/history-window.ts
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
import type { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
|
import { createEffect, createMemo, on } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import { same } from "@/utils/same"
|
||||||
|
|
||||||
|
export const emptyUserMessages: UserMessage[] = []
|
||||||
|
|
||||||
|
export type SessionHistoryWindowInput = {
|
||||||
|
sessionID: () => string | undefined
|
||||||
|
messagesReady: () => boolean
|
||||||
|
visibleUserMessages: () => UserMessage[]
|
||||||
|
historyMore: () => boolean
|
||||||
|
historyLoading: () => boolean
|
||||||
|
loadMore: (sessionID: string) => Promise<void>
|
||||||
|
userScrolled: () => boolean
|
||||||
|
scroller: () => HTMLDivElement | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snap = {
|
||||||
|
top: number
|
||||||
|
height: number
|
||||||
|
gap: number
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const historyLoadMode = (input: { start: number; more: boolean; loading: boolean }) => {
|
||||||
|
if (input.start > 0) return "reveal"
|
||||||
|
if (!input.more || input.loading) return "noop"
|
||||||
|
return "fetch"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const historyRevealTop = (
|
||||||
|
mark: { top: number; height: number; gap: number; max: number },
|
||||||
|
next: { clientHeight: number; height: number },
|
||||||
|
threshold = 16,
|
||||||
|
) => {
|
||||||
|
const delta = next.height - mark.height
|
||||||
|
if (delta <= 0) return mark.top
|
||||||
|
if (mark.max <= 0) return mark.top
|
||||||
|
if (mark.gap > threshold) return mark.top
|
||||||
|
|
||||||
|
const max = next.height - next.clientHeight
|
||||||
|
if (max <= 0) return 0
|
||||||
|
return Math.max(-max, Math.min(0, mark.top - delta))
|
||||||
|
}
|
||||||
|
|
||||||
|
const snap = (el: HTMLDivElement | undefined): Snap | undefined => {
|
||||||
|
if (!el) return
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
return {
|
||||||
|
top: el.scrollTop,
|
||||||
|
height: el.scrollHeight,
|
||||||
|
gap: max + el.scrollTop,
|
||||||
|
max,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (el: HTMLDivElement, top: number) => {
|
||||||
|
const max = el.scrollHeight - el.clientHeight
|
||||||
|
if (max <= 0) return 0
|
||||||
|
return Math.max(-max, Math.min(0, top))
|
||||||
|
}
|
||||||
|
|
||||||
|
const revealThreshold = 16
|
||||||
|
|
||||||
|
const reveal = (input: SessionHistoryWindowInput, mark: Snap | undefined) => {
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el || !mark) return
|
||||||
|
el.scrollTop = clamp(
|
||||||
|
el,
|
||||||
|
historyRevealTop(mark, { clientHeight: el.clientHeight, height: el.scrollHeight }, revealThreshold),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const preserve = (input: SessionHistoryWindowInput, fn: () => void) => {
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el) {
|
||||||
|
fn()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const top = el.scrollTop
|
||||||
|
fn()
|
||||||
|
el.scrollTop = top
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maintains the rendered history window for a session timeline.
|
||||||
|
*
|
||||||
|
* It keeps initial paint bounded to recent turns, reveals cached turns in
|
||||||
|
* small batches while scrolling upward, and prefetches older history near top.
|
||||||
|
*/
|
||||||
|
export function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
|
||||||
|
const turnInit = 10
|
||||||
|
const turnBatch = 8
|
||||||
|
const turnScrollThreshold = 200
|
||||||
|
const turnPrefetchBuffer = 16
|
||||||
|
const prefetchCooldownMs = 400
|
||||||
|
const prefetchNoGrowthLimit = 2
|
||||||
|
|
||||||
|
const [state, setState] = createStore({
|
||||||
|
turnID: undefined as string | undefined,
|
||||||
|
turnStart: 0,
|
||||||
|
prefetchUntil: 0,
|
||||||
|
prefetchNoGrowth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const initialTurnStart = (len: number) => (len > turnInit ? len - turnInit : 0)
|
||||||
|
|
||||||
|
const turnStart = createMemo(() => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
const len = input.visibleUserMessages().length
|
||||||
|
if (!id || len <= 0) return 0
|
||||||
|
if (state.turnID !== id) return initialTurnStart(len)
|
||||||
|
if (state.turnStart <= 0) return 0
|
||||||
|
if (state.turnStart >= len) return initialTurnStart(len)
|
||||||
|
return state.turnStart
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTurnStart = (start: number) => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
const next = start > 0 ? start : 0
|
||||||
|
if (!id) {
|
||||||
|
setState({ turnID: undefined, turnStart: next })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setState({ turnID: id, turnStart: next })
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderedUserMessages = createMemo(
|
||||||
|
() => {
|
||||||
|
const msgs = input.visibleUserMessages()
|
||||||
|
const start = turnStart()
|
||||||
|
if (start <= 0) return msgs
|
||||||
|
return msgs.slice(start)
|
||||||
|
},
|
||||||
|
emptyUserMessages,
|
||||||
|
{
|
||||||
|
equals: same,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const backfillTurns = () => {
|
||||||
|
const start = turnStart()
|
||||||
|
if (start <= 0) return
|
||||||
|
|
||||||
|
const next = start - turnBatch
|
||||||
|
const nextStart = next > 0 ? next : 0
|
||||||
|
|
||||||
|
preserve(input, () => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Button path: reveal cached turns first, then fetch older history. */
|
||||||
|
const loadAndReveal = async () => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
const mode = historyLoadMode({
|
||||||
|
start,
|
||||||
|
more: input.historyMore(),
|
||||||
|
loading: input.historyLoading(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (mode === "reveal") {
|
||||||
|
const mark = snap(input.scroller())
|
||||||
|
setTurnStart(0)
|
||||||
|
reveal(input, mark)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "noop") return
|
||||||
|
|
||||||
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
|
const mark = snap(input.scroller())
|
||||||
|
|
||||||
|
await input.loadMore(id)
|
||||||
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
|
const afterVisible = input.visibleUserMessages().length
|
||||||
|
const growth = afterVisible - beforeVisible
|
||||||
|
if (growth <= 0) return
|
||||||
|
if (state.prefetchNoGrowth) setState("prefetchNoGrowth", 0)
|
||||||
|
|
||||||
|
reveal(input, mark)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scroll/prefetch path: fetch older history from server. */
|
||||||
|
const fetchOlderMessages = async (opts?: { prefetch?: boolean }) => {
|
||||||
|
const id = input.sessionID()
|
||||||
|
if (!id) return
|
||||||
|
if (!input.historyMore() || input.historyLoading()) return
|
||||||
|
|
||||||
|
if (opts?.prefetch) {
|
||||||
|
const now = Date.now()
|
||||||
|
if (state.prefetchUntil > now) return
|
||||||
|
if (state.prefetchNoGrowth >= prefetchNoGrowthLimit) return
|
||||||
|
setState("prefetchUntil", now + prefetchCooldownMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
const beforeVisible = input.visibleUserMessages().length
|
||||||
|
const beforeRendered = start <= 0 ? beforeVisible : renderedUserMessages().length
|
||||||
|
|
||||||
|
await input.loadMore(id)
|
||||||
|
if (input.sessionID() !== id) return
|
||||||
|
|
||||||
|
const afterVisible = input.visibleUserMessages().length
|
||||||
|
const growth = afterVisible - beforeVisible
|
||||||
|
|
||||||
|
if (opts?.prefetch) {
|
||||||
|
setState("prefetchNoGrowth", growth > 0 ? 0 : state.prefetchNoGrowth + 1)
|
||||||
|
} else if (growth > 0 && state.prefetchNoGrowth) {
|
||||||
|
setState("prefetchNoGrowth", 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (growth <= 0) return
|
||||||
|
if (turnStart() !== start) return
|
||||||
|
|
||||||
|
const revealMore = !opts?.prefetch
|
||||||
|
const currentRendered = renderedUserMessages().length
|
||||||
|
const base = Math.max(beforeRendered, currentRendered)
|
||||||
|
const target = revealMore ? Math.min(afterVisible, base + turnBatch) : base
|
||||||
|
const nextStart = Math.max(0, afterVisible - target)
|
||||||
|
preserve(input, () => setTurnStart(nextStart))
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScrollerScroll = () => {
|
||||||
|
if (!input.userScrolled()) return
|
||||||
|
const el = input.scroller()
|
||||||
|
if (!el) return
|
||||||
|
if (el.scrollHeight - el.clientHeight + el.scrollTop >= turnScrollThreshold) return
|
||||||
|
|
||||||
|
const start = turnStart()
|
||||||
|
if (start > 0) {
|
||||||
|
if (start <= turnPrefetchBuffer) {
|
||||||
|
void fetchOlderMessages({ prefetch: true })
|
||||||
|
}
|
||||||
|
backfillTurns()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchOlderMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
input.sessionID,
|
||||||
|
() => {
|
||||||
|
setState({ prefetchUntil: 0, prefetchNoGrowth: 0 })
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
createEffect(
|
||||||
|
on(
|
||||||
|
() => [input.sessionID(), input.messagesReady()] as const,
|
||||||
|
([id, ready]) => {
|
||||||
|
if (!id || !ready) return
|
||||||
|
setTurnStart(initialTurnStart(input.visibleUserMessages().length))
|
||||||
|
},
|
||||||
|
{ defer: true },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnStart,
|
||||||
|
setTurnStart,
|
||||||
|
renderedUserMessages,
|
||||||
|
loadAndReveal,
|
||||||
|
onScrollerScroll,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,10 @@ import { createEffect, createMemo, on, onCleanup, Show } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { useNavigate, useParams } from "@solidjs/router"
|
import { useNavigate, useParams } from "@solidjs/router"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
|
import { useReducedMotion } from "@opencode-ai/ui/hooks"
|
||||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { prefersReducedMotion } from "@opencode-ai/ui/hooks"
|
|
||||||
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
import { InlineInput } from "@opencode-ai/ui/inline-input"
|
||||||
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion"
|
||||||
import { showToast } from "@opencode-ai/ui/toast"
|
import { showToast } from "@opencode-ai/ui/toast"
|
||||||
|
|
@ -32,7 +32,7 @@ export function SessionTimelineHeader(props: {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const reduce = prefersReducedMotion
|
const reduce = useReducedMotion()
|
||||||
|
|
||||||
const [title, setTitle] = createStore({
|
const [title, setTitle] = createStore({
|
||||||
draft: "",
|
draft: "",
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,7 @@ export function TerminalPanel() {
|
||||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||||
<Terminal
|
<Terminal
|
||||||
pty={pty()}
|
pty={pty()}
|
||||||
|
onConnect={() => terminal.trim(id)}
|
||||||
onCleanup={terminal.update}
|
onCleanup={terminal.update}
|
||||||
onConnectError={() => terminal.clone(id)}
|
onConnectError={() => terminal.clone(id)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.2.21"
|
version = "1.2.22"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
|
|
@ -11,26 +11,26 @@ name = "OpenCode"
|
||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[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.22/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[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.22/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[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.22/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[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.22/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[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.22/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,6 @@ export function tui(input: {
|
||||||
fetch?: typeof fetch
|
fetch?: typeof fetch
|
||||||
headers?: RequestInit["headers"]
|
headers?: RequestInit["headers"]
|
||||||
events?: EventSource
|
events?: EventSource
|
||||||
onExit?: () => Promise<void>
|
|
||||||
}) {
|
}) {
|
||||||
// promise to prevent immediate exit
|
// promise to prevent immediate exit
|
||||||
return new Promise<void>(async (resolve) => {
|
return new Promise<void>(async (resolve) => {
|
||||||
|
|
@ -126,7 +125,6 @@ export function tui(input: {
|
||||||
|
|
||||||
const onExit = async () => {
|
const onExit = async () => {
|
||||||
unguard?.()
|
unguard?.()
|
||||||
await input.onExit?.()
|
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||||
init: (input: { onExit?: () => Promise<void> }) => {
|
init: (input: { onExit?: () => Promise<void> }) => {
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
let message: string | undefined
|
let message: string | undefined
|
||||||
|
let task: Promise<void> | undefined
|
||||||
const store = {
|
const store = {
|
||||||
set: (value?: string) => {
|
set: (value?: string) => {
|
||||||
const prev = message
|
const prev = message
|
||||||
|
|
@ -29,20 +30,24 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
|
||||||
get: () => message,
|
get: () => message,
|
||||||
}
|
}
|
||||||
const exit: Exit = Object.assign(
|
const exit: Exit = Object.assign(
|
||||||
async (reason?: unknown) => {
|
(reason?: unknown) => {
|
||||||
// Reset window title before destroying renderer
|
if (task) return task
|
||||||
renderer.setTerminalTitle("")
|
task = (async () => {
|
||||||
renderer.destroy()
|
// Reset window title before destroying renderer
|
||||||
win32FlushInputBuffer()
|
renderer.setTerminalTitle("")
|
||||||
if (reason) {
|
renderer.destroy()
|
||||||
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
win32FlushInputBuffer()
|
||||||
if (formatted) {
|
if (reason) {
|
||||||
process.stderr.write(formatted + "\n")
|
const formatted = FormatError(reason) ?? FormatUnknownError(reason)
|
||||||
|
if (formatted) {
|
||||||
|
process.stderr.write(formatted + "\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const text = store.get()
|
||||||
const text = store.get()
|
if (text) process.stdout.write(text + "\n")
|
||||||
if (text) process.stdout.write(text + "\n")
|
await input.onExit?.()
|
||||||
await input.onExit?.()
|
})()
|
||||||
|
return task
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message: store,
|
message: store,
|
||||||
|
|
|
||||||
|
|
@ -110,18 +110,20 @@ export const TuiThreadCommand = cmd({
|
||||||
return
|
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 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))
|
? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project))
|
||||||
: root
|
: Filesystem.resolve(process.cwd())
|
||||||
const file = await target()
|
const file = await target()
|
||||||
try {
|
try {
|
||||||
process.chdir(cwd)
|
process.chdir(next)
|
||||||
} catch {
|
} catch {
|
||||||
UI.error("Failed to change directory to " + cwd)
|
UI.error("Failed to change directory to " + next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const cwd = Filesystem.resolve(process.cwd())
|
||||||
|
|
||||||
const worker = new Worker(file, {
|
const worker = new Worker(file, {
|
||||||
env: Object.fromEntries(
|
env: Object.fromEntries(
|
||||||
|
|
|
||||||
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(".")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
@ -48,8 +48,11 @@
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@shikijs/transformers": "3.9.2",
|
"@shikijs/transformers": "3.9.2",
|
||||||
"@solid-primitives/bounds": "0.1.3",
|
"@solid-primitives/bounds": "0.1.3",
|
||||||
|
"@solid-primitives/lifecycle": "0.1.2",
|
||||||
"@solid-primitives/media": "2.3.3",
|
"@solid-primitives/media": "2.3.3",
|
||||||
|
"@solid-primitives/page-visibility": "2.1.1",
|
||||||
"@solid-primitives/resize-observer": "2.1.3",
|
"@solid-primitives/resize-observer": "2.1.3",
|
||||||
|
"@solid-primitives/rootless": "1.5.2",
|
||||||
"@solidjs/meta": "catalog:",
|
"@solidjs/meta": "catalog:",
|
||||||
"@solidjs/router": "catalog:",
|
"@solidjs/router": "catalog:",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { createMemo, createSignal, For, onMount } from "solid-js"
|
import { createMemo, createSignal, For, onMount } from "solid-js"
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { getFilename } from "@opencode-ai/util/path"
|
import { getFilename } from "@opencode-ai/util/path"
|
||||||
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import { ToolCall } from "./basic-tool"
|
import { ToolCall } from "./basic-tool"
|
||||||
import { ToolStatusTitle } from "./tool-status-title"
|
import { ToolStatusTitle } from "./tool-status-title"
|
||||||
import { AnimatedCountList } from "./tool-count-summary"
|
import { AnimatedCountList } from "./tool-count-summary"
|
||||||
|
|
@ -58,11 +58,9 @@ export function ContextToolGroupHeader(props: {
|
||||||
<ToolCall
|
<ToolCall
|
||||||
variant="row"
|
variant="row"
|
||||||
icon="magnifying-glass-menu"
|
icon="magnifying-glass-menu"
|
||||||
open={!props.pending && props.open}
|
open={props.open}
|
||||||
showArrow={!props.pending}
|
showArrow
|
||||||
onOpenChange={(v) => {
|
onOpenChange={props.onOpenChange}
|
||||||
if (!props.pending) props.onOpenChange(v)
|
|
||||||
}}
|
|
||||||
trigger={
|
trigger={
|
||||||
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
|
<div data-component="context-tool-group-trigger" data-pending={props.pending || undefined}>
|
||||||
<span
|
<span
|
||||||
|
|
@ -149,10 +147,10 @@ export function ContextToolExpandedList(props: { parts: ToolPart[]; expanded: bo
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
|
export function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean }) {
|
||||||
|
const reduce = useReducedMotion()
|
||||||
const wiped = new Set<string>()
|
const wiped = new Set<string>()
|
||||||
const [mounted, setMounted] = createSignal(false)
|
const [mounted, setMounted] = createSignal(false)
|
||||||
onMount(() => setMounted(true))
|
onMount(() => setMounted(true))
|
||||||
const reduce = prefersReducedMotion
|
|
||||||
const show = () => mounted() && props.pending
|
const show = () => mounted() && props.pending
|
||||||
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
|
const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING)
|
||||||
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
|
const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
|
import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js"
|
||||||
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
|
import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
|
|
||||||
export interface GrowBoxProps {
|
export interface GrowBoxProps {
|
||||||
children: JSX.Element
|
children: JSX.Element
|
||||||
|
|
@ -49,7 +49,7 @@ export interface GrowBoxProps {
|
||||||
* Used for timeline turns, assistant part groups, and user messages.
|
* Used for timeline turns, assistant part groups, and user messages.
|
||||||
*/
|
*/
|
||||||
export function GrowBox(props: GrowBoxProps) {
|
export function GrowBox(props: GrowBoxProps) {
|
||||||
const reduce = prefersReducedMotion
|
const reduce = useReducedMotion()
|
||||||
const spring = () => props.spring ?? GROW_SPRING
|
const spring = () => props.spring ?? GROW_SPRING
|
||||||
const toggleSpring = () => props.toggleSpring ?? spring()
|
const toggleSpring = () => props.toggleSpring ?? spring()
|
||||||
let mode: "mount" | "toggle" = "mount"
|
let mode: "mount" | "toggle" = "mount"
|
||||||
|
|
@ -293,6 +293,18 @@ export function GrowBox(props: GrowBoxProps) {
|
||||||
offChange()
|
offChange()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (watch()) {
|
||||||
|
observer = new ResizeObserver(() => {
|
||||||
|
if (!open()) return
|
||||||
|
if (resizeFrame !== undefined) return
|
||||||
|
resizeFrame = requestAnimationFrame(() => {
|
||||||
|
resizeFrame = undefined
|
||||||
|
setHeight("mount")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
observer.observe(body)
|
||||||
|
}
|
||||||
|
|
||||||
if (!animated()) {
|
if (!animated()) {
|
||||||
setInstant(open())
|
setInstant(open())
|
||||||
return
|
return
|
||||||
|
|
@ -318,17 +330,6 @@ export function GrowBox(props: GrowBoxProps) {
|
||||||
if (grow()) setHeight("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(
|
createEffect(
|
||||||
|
|
@ -402,7 +403,12 @@ export function GrowBox(props: GrowBoxProps) {
|
||||||
ref={root}
|
ref={root}
|
||||||
data-slot={props.slot}
|
data-slot={props.slot}
|
||||||
class={props.class}
|
class={props.class}
|
||||||
style={{ transform: "translateZ(0)", position: "relative" }}
|
style={{
|
||||||
|
transform: "translateZ(0)",
|
||||||
|
position: "relative",
|
||||||
|
height: open() ? undefined : "0px",
|
||||||
|
overflow: open() ? undefined : "clip",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
|
<div ref={body} style={{ "padding-top": gap() > 0 ? `${gap()}px` : undefined }}>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ import { TextShimmer } from "./text-shimmer"
|
||||||
import { list } from "./text-utils"
|
import { list } from "./text-utils"
|
||||||
import { GrowBox } from "./grow-box"
|
import { GrowBox } from "./grow-box"
|
||||||
import { COLLAPSIBLE_SPRING } from "./motion"
|
import { COLLAPSIBLE_SPRING } from "./motion"
|
||||||
import { busy, hold, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
|
import { busy, createThrottledValue, useToolFade, useContextToolPending } from "./tool-utils"
|
||||||
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
|
import { ContextToolGroupHeader, ContextToolExpandedList, ContextToolRollingResults } from "./context-tool-results"
|
||||||
import { ShellRollingResults } from "./shell-rolling-results"
|
import { ShellRollingResults } from "./shell-rolling-results"
|
||||||
|
|
||||||
|
|
@ -254,8 +254,6 @@ function urls(text: string | undefined) {
|
||||||
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
|
||||||
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
|
const HIDDEN_TOOLS = new Set(["todowrite", "todoread"])
|
||||||
|
|
||||||
import { pageVisible } from "../hooks/use-page-visible"
|
|
||||||
|
|
||||||
function createGroupOpenState() {
|
function createGroupOpenState() {
|
||||||
const [state, setState] = createStore<Record<string, boolean>>({})
|
const [state, setState] = createStore<Record<string, boolean>>({})
|
||||||
const read = (key?: string, collapse?: boolean) => {
|
const read = (key?: string, collapse?: boolean) => {
|
||||||
|
|
@ -274,18 +272,6 @@ function createGroupOpenState() {
|
||||||
return { read, controlled, write }
|
return { read, controlled, write }
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldCollapseGroup(
|
|
||||||
statuses: (string | undefined)[],
|
|
||||||
opts: { afterTool?: boolean; groupTail?: boolean; working?: boolean },
|
|
||||||
) {
|
|
||||||
if (opts.afterTool) return true
|
|
||||||
if (opts.groupTail === false) return true
|
|
||||||
if (!pageVisible()) return false
|
|
||||||
if (opts.working) return false
|
|
||||||
if (!statuses.length) return false
|
|
||||||
return !statuses.some((s) => busy(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderable(part: PartType, showReasoningSummaries = true) {
|
function renderable(part: PartType, showReasoningSummaries = true) {
|
||||||
if (part.type === "tool") {
|
if (part.type === "tool") {
|
||||||
if (HIDDEN_TOOLS.has(part.tool)) return false
|
if (HIDDEN_TOOLS.has(part.tool)) return false
|
||||||
|
|
@ -480,20 +466,9 @@ export function AssistantParts(props: {
|
||||||
return COLLAPSIBLE_SPRING
|
return COLLAPSIBLE_SPRING
|
||||||
})
|
})
|
||||||
const contextOpen = createMemo(() => {
|
const contextOpen = createMemo(() => {
|
||||||
const collapse = (
|
|
||||||
afterTool?: boolean,
|
|
||||||
groupTail?: boolean,
|
|
||||||
group?: { part: ToolPart; message: AssistantMessage }[],
|
|
||||||
) =>
|
|
||||||
shouldCollapseGroup(group?.map((item) => item.part.state.status) ?? [], {
|
|
||||||
afterTool,
|
|
||||||
groupTail,
|
|
||||||
working: props.working,
|
|
||||||
})
|
|
||||||
const value = ctx()
|
const value = ctx()
|
||||||
if (value) return groupState.read(value.groupKey, collapse(value.afterTool, value.tail, value.parts))
|
if (value) return groupState.read(value.groupKey, true)
|
||||||
const entry = part()
|
return groupState.read(part()?.groupKey, true)
|
||||||
return groupState.read(entry?.groupKey, collapse(entry?.afterTool, entry?.groupTail, entry?.groupParts))
|
|
||||||
})
|
})
|
||||||
const visible = createMemo(() => {
|
const visible = createMemo(() => {
|
||||||
if (!context()) return true
|
if (!context()) return true
|
||||||
|
|
@ -539,9 +514,7 @@ export function AssistantParts(props: {
|
||||||
ctxPartsPrev = result
|
ctxPartsPrev = result
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
const ctxPendingRaw = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
|
const ctxPending = useContextToolPending(ctxParts, () => !!(props.working && ctx()?.tail))
|
||||||
const ctxPending = ctxPendingRaw
|
|
||||||
const ctxHoldOpen = hold(ctxPendingRaw)
|
|
||||||
const shell = createMemo(() => {
|
const shell = createMemo(() => {
|
||||||
const value = part()
|
const value = part()
|
||||||
if (!value) return
|
if (!value) return
|
||||||
|
|
@ -593,12 +566,20 @@ export function AssistantParts(props: {
|
||||||
onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)}
|
onOpenChange={(value: boolean) => groupState.write(entry().groupKey, value)}
|
||||||
/>
|
/>
|
||||||
</PartGrow>
|
</PartGrow>
|
||||||
<ContextToolExpandedList parts={ctxParts()} expanded={!ctxPending() && contextOpen()} />
|
<ContextToolExpandedList parts={ctxParts()} expanded={contextOpen() && !ctxPending()} />
|
||||||
<ContextToolRollingResults parts={ctxParts()} pending={ctxHoldOpen()} />
|
<ContextToolRollingResults parts={ctxParts()} pending={contextOpen() && ctxPending()} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Show>
|
</Show>
|
||||||
<Show when={shell()}>{(value) => <ShellRollingResults part={value()} animate={props.animate} />}</Show>
|
<Show when={shell()}>
|
||||||
|
{(value) => (
|
||||||
|
<ShellRollingResults
|
||||||
|
part={value()}
|
||||||
|
animate={props.animate}
|
||||||
|
defaultOpen={props.shellToolDefaultOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
<Show when={!shell() ? part() : undefined}>
|
<Show when={!shell() ? part() : undefined}>
|
||||||
{(entry) => (
|
{(entry) => (
|
||||||
<Show when={!entry().context}>
|
<Show when={!entry().context}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { attachSpring, motionValue } from "motion"
|
import { attachSpring, motionValue } from "motion"
|
||||||
import type { SpringOptions } from "motion"
|
import type { SpringOptions } from "motion"
|
||||||
import { createEffect, createSignal, onCleanup } from "solid-js"
|
import { createEffect, createSignal, onCleanup } from "solid-js"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
|
|
||||||
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
|
type Opt = Pick<SpringOptions, "visualDuration" | "bounce" | "stiffness" | "damping" | "mass" | "velocity">
|
||||||
const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
||||||
|
|
@ -14,7 +14,7 @@ const eq = (a: Opt | undefined, b: Opt | undefined) =>
|
||||||
|
|
||||||
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
export function useSpring(target: () => number, options?: Opt | (() => Opt)) {
|
||||||
const read = () => (typeof options === "function" ? options() : options)
|
const read = () => (typeof options === "function" ? options() : options)
|
||||||
const reduce = prefersReducedMotion
|
const reduce = useReducedMotion()
|
||||||
const [value, setValue] = createSignal(target())
|
const [value, setValue] = createSignal(target())
|
||||||
const source = motionValue(value())
|
const source = motionValue(value())
|
||||||
const spring = motionValue(value())
|
const spring = motionValue(value())
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
|
import { For, Show, batch, createEffect, createMemo, createSignal, on, onCleanup, onMount, type JSX } from "solid-js"
|
||||||
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
|
import { animate, clearMaskStyles, GROW_SPRING, type AnimationPlaybackControls, type SpringConfig } from "./motion"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
|
|
||||||
export type RollingResultsProps<T> = {
|
export type RollingResultsProps<T> = {
|
||||||
items: T[]
|
items: T[]
|
||||||
|
|
@ -27,8 +27,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
|
||||||
let shift: AnimationPlaybackControls | undefined
|
let shift: AnimationPlaybackControls | undefined
|
||||||
let resize: AnimationPlaybackControls | undefined
|
let resize: AnimationPlaybackControls | undefined
|
||||||
let edgeFade: AnimationPlaybackControls | undefined
|
let edgeFade: AnimationPlaybackControls | undefined
|
||||||
|
const reduce = useReducedMotion()
|
||||||
const reducedMotion = prefersReducedMotion
|
|
||||||
|
|
||||||
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
|
const rows = createMemo(() => Math.max(1, Math.round(props.rows ?? 3)))
|
||||||
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
|
const rowHeight = createMemo(() => Math.max(16, Math.round(props.rowHeight ?? 22)))
|
||||||
|
|
@ -54,7 +53,7 @@ export function RollingResults<T>(props: RollingResultsProps<T>) {
|
||||||
return count() - rendered().length
|
return count() - rendered().length
|
||||||
})
|
})
|
||||||
const open = createMemo(() => props.open !== false)
|
const open = createMemo(() => props.open !== false)
|
||||||
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reducedMotion())
|
const active = createMemo(() => (props.animate !== false || props.spring !== undefined) && !reduce())
|
||||||
const noFade = () => props.noFadeOnCollapse === true
|
const noFade = () => props.noFadeOnCollapse === true
|
||||||
const overflowing = createMemo(() => count() > rows())
|
const overflowing = createMemo(() => count() > rows())
|
||||||
const shown = createMemo(() => Math.min(rows(), count()))
|
const shown = createMemo(() => Math.min(rows(), count()))
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import { useI18n } from "../context/i18n"
|
import { useI18n } from "../context/i18n"
|
||||||
import { RollingResults } from "./rolling-results"
|
import { RollingResults } from "./rolling-results"
|
||||||
import { Icon } from "./icon"
|
import { Icon } from "./icon"
|
||||||
|
|
@ -10,15 +10,7 @@ import { TextShimmer } from "./text-shimmer"
|
||||||
import { Tooltip } from "./tooltip"
|
import { Tooltip } from "./tooltip"
|
||||||
import { GROW_SPRING } from "./motion"
|
import { GROW_SPRING } from "./motion"
|
||||||
import { useSpring } from "./motion-spring"
|
import { useSpring } from "./motion-spring"
|
||||||
import {
|
import { busy, createThrottledValue, updateScrollMask, useCollapsible, useRowWipe, useToolFade } from "./tool-utils"
|
||||||
busy,
|
|
||||||
createThrottledValue,
|
|
||||||
hold,
|
|
||||||
updateScrollMask,
|
|
||||||
useCollapsible,
|
|
||||||
useRowWipe,
|
|
||||||
useToolFade,
|
|
||||||
} from "./tool-utils"
|
|
||||||
|
|
||||||
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
|
function ShellRollingSubtitle(props: { text: string; animate?: boolean }) {
|
||||||
let ref: HTMLSpanElement | undefined
|
let ref: HTMLSpanElement | undefined
|
||||||
|
|
@ -176,23 +168,17 @@ function ShellExpanded(props: { cmd: string; out: string; open: boolean }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }) {
|
export function ShellRollingResults(props: { part: ToolPart; animate?: boolean; defaultOpen?: boolean }) {
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
|
const reduce = useReducedMotion()
|
||||||
const wiped = new Set<string>()
|
const wiped = new Set<string>()
|
||||||
const [mounted, setMounted] = createSignal(false)
|
const [mounted, setMounted] = createSignal(false)
|
||||||
const [userToggled, setUserToggled] = createSignal(false)
|
const [open, setOpen] = createSignal(props.defaultOpen ?? true)
|
||||||
const [userOpen, setUserOpen] = createSignal(false)
|
|
||||||
onMount(() => setMounted(true))
|
onMount(() => setMounted(true))
|
||||||
const state = createMemo(() => props.part.state as Record<string, any>)
|
const state = createMemo(() => props.part.state as Record<string, any>)
|
||||||
const pending = createMemo(() => busy(props.part.state.status))
|
const pending = createMemo(() => busy(props.part.state.status))
|
||||||
const autoOpen = hold(pending, 2000)
|
const expanded = createMemo(() => open() && !pending())
|
||||||
const effectiveOpen = createMemo(() => {
|
const previewOpen = createMemo(() => open() && pending())
|
||||||
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 command = createMemo(() => {
|
||||||
const value = state().input?.command ?? state().metadata?.command
|
const value = state().input?.command ?? state().metadata?.command
|
||||||
if (typeof value === "string") return value
|
if (typeof value === "string") return value
|
||||||
|
|
@ -208,7 +194,6 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
||||||
if (typeof value === "string") return value
|
if (typeof value === "string") return value
|
||||||
return ""
|
return ""
|
||||||
})
|
})
|
||||||
const reduce = prefersReducedMotion
|
|
||||||
const skip = () => reduce() || props.animate === false
|
const skip = () => reduce() || props.animate === false
|
||||||
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
|
const opacity = useSpring(() => (mounted() ? 1 : 0), GROW_SPRING)
|
||||||
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
|
const blur = useSpring(() => (mounted() ? 0 : 2), GROW_SPRING)
|
||||||
|
|
@ -217,12 +202,10 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
||||||
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
|
const headerHeight = useSpring(() => (mounted() ? 37 : 0), GROW_SPRING)
|
||||||
let headerClipRef: HTMLDivElement | undefined
|
let headerClipRef: HTMLDivElement | undefined
|
||||||
const handleHeaderClick = () => {
|
const handleHeaderClick = () => {
|
||||||
if (pending()) return
|
|
||||||
const el = headerClipRef
|
const el = headerClipRef
|
||||||
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
|
const viewport = el?.closest(".scroll-view__viewport") as HTMLElement | null
|
||||||
const beforeY = el?.getBoundingClientRect().top ?? 0
|
const beforeY = el?.getBoundingClientRect().top ?? 0
|
||||||
setUserToggled(true)
|
setOpen((prev) => !prev)
|
||||||
setUserOpen((prev) => !prev)
|
|
||||||
if (viewport && el) {
|
if (viewport && el) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const afterY = el.getBoundingClientRect().top
|
const afterY = el.getBoundingClientRect().top
|
||||||
|
|
@ -249,7 +232,7 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
||||||
ref={headerClipRef}
|
ref={headerClipRef}
|
||||||
data-slot="shell-rolling-header-clip"
|
data-slot="shell-rolling-header-clip"
|
||||||
data-scroll-preserve
|
data-scroll-preserve
|
||||||
data-clickable={!pending() ? "true" : "false"}
|
data-clickable="true"
|
||||||
onClick={handleHeaderClick}
|
onClick={handleHeaderClick}
|
||||||
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
|
style={{ height: `${skip() ? (mounted() ? 37 : 0) : headerHeight()}px`, overflow: "clip" }}
|
||||||
>
|
>
|
||||||
|
|
@ -258,13 +241,11 @@ export function ShellRollingResults(props: { part: ToolPart; animate?: boolean }
|
||||||
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
|
||||||
</span>
|
</span>
|
||||||
<Show when={subtitle()}>{(text) => <ShellRollingSubtitle text={text()} animate={props.animate} />}</Show>
|
<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-actions">
|
<span data-slot="shell-rolling-arrow" data-open={open() ? "true" : "false"}>
|
||||||
<span data-slot="shell-rolling-arrow" data-open={effectiveOpen() ? "true" : "false"}>
|
<Icon name="chevron-down" size="small" />
|
||||||
<Icon name="chevron-down" size="small" />
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</Show>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
|
import { createEffect, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||||
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import {
|
import {
|
||||||
animate,
|
animate,
|
||||||
type AnimationPlaybackControls,
|
type AnimationPlaybackControls,
|
||||||
|
|
@ -7,7 +8,6 @@ import {
|
||||||
GROW_SPRING,
|
GROW_SPRING,
|
||||||
WIPE_MASK,
|
WIPE_MASK,
|
||||||
} from "./motion"
|
} from "./motion"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
|
|
||||||
const px = (value: number | string | undefined, fallback: number) => {
|
const px = (value: number | string | undefined, fallback: number) => {
|
||||||
if (typeof value === "number") return `${value}px`
|
if (typeof value === "number") return `${value}px`
|
||||||
|
|
@ -143,12 +143,13 @@ export function TextWipe(props: { text?: string; class?: string; delay?: number;
|
||||||
let ref: HTMLSpanElement | undefined
|
let ref: HTMLSpanElement | undefined
|
||||||
let frame: number | undefined
|
let frame: number | undefined
|
||||||
let anim: AnimationPlaybackControls | undefined
|
let anim: AnimationPlaybackControls | undefined
|
||||||
|
const reduce = useReducedMotion()
|
||||||
|
|
||||||
const run = () => {
|
const run = () => {
|
||||||
if (props.animate === false) return
|
if (props.animate === false) return
|
||||||
const el = ref
|
const el = ref
|
||||||
if (!el || !props.text || typeof window === "undefined") return
|
if (!el || !props.text || typeof window === "undefined") return
|
||||||
if (prefersReducedMotion()) return
|
if (reduce()) return
|
||||||
|
|
||||||
const mask =
|
const mask =
|
||||||
typeof CSS !== "undefined" &&
|
typeof CSS !== "undefined" &&
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||||
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
|
import { animate, type AnimationPlaybackControls, GROW_SPRING } from "./motion"
|
||||||
import { TextShimmer } from "./text-shimmer"
|
import { TextShimmer } from "./text-shimmer"
|
||||||
import { commonPrefix } from "./text-utils"
|
import { commonPrefix } from "./text-utils"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
|
|
||||||
function contentWidth(el: HTMLSpanElement | undefined) {
|
function contentWidth(el: HTMLSpanElement | undefined) {
|
||||||
if (!el) return 0
|
if (!el) return 0
|
||||||
|
|
@ -18,6 +18,7 @@ export function ToolStatusTitle(props: {
|
||||||
class?: string
|
class?: string
|
||||||
split?: boolean
|
split?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const reduce = useReducedMotion()
|
||||||
const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
|
const split = createMemo(() => commonPrefix(props.activeText, props.doneText))
|
||||||
const suffix = createMemo(
|
const suffix = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -38,8 +39,6 @@ export function ToolStatusTitle(props: {
|
||||||
|
|
||||||
const node = () => (suffix() ? tailRef : swapRef)
|
const node = () => (suffix() ? tailRef : swapRef)
|
||||||
|
|
||||||
const reduce = prefersReducedMotion
|
|
||||||
|
|
||||||
const setNodeWidth = (width: string) => {
|
const setNodeWidth = (width: string) => {
|
||||||
if (swapRef) swapRef.style.width = width
|
if (swapRef) swapRef.style.width = width
|
||||||
if (tailRef) tailRef.style.width = width
|
if (tailRef) tailRef.style.width = width
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
||||||
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
import { createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js"
|
||||||
|
import { useReducedMotion } from "../hooks/use-reduced-motion"
|
||||||
import {
|
import {
|
||||||
animate,
|
animate,
|
||||||
type AnimationPlaybackControls,
|
type AnimationPlaybackControls,
|
||||||
|
|
@ -8,8 +10,6 @@ import {
|
||||||
GROW_SPRING,
|
GROW_SPRING,
|
||||||
WIPE_MASK,
|
WIPE_MASK,
|
||||||
} from "./motion"
|
} from "./motion"
|
||||||
import { prefersReducedMotion } from "../hooks/use-reduced-motion"
|
|
||||||
import type { ToolPart } from "@opencode-ai/sdk/v2"
|
|
||||||
|
|
||||||
export const TEXT_RENDER_THROTTLE_MS = 100
|
export const TEXT_RENDER_THROTTLE_MS = 100
|
||||||
|
|
||||||
|
|
@ -106,57 +106,67 @@ export function useCollapsible(options: {
|
||||||
measure?: () => number
|
measure?: () => number
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const reduce = useReducedMotion()
|
||||||
let heightAnim: AnimationPlaybackControls | undefined
|
let heightAnim: AnimationPlaybackControls | undefined
|
||||||
let fadeAnim: AnimationPlaybackControls | undefined
|
let fadeAnim: AnimationPlaybackControls | undefined
|
||||||
let gen = 0
|
let gen = 0
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(options.open, (isOpen) => {
|
||||||
options.open,
|
const content = options.content()
|
||||||
(isOpen) => {
|
const body = options.body()
|
||||||
const content = options.content()
|
if (!content || !body) return
|
||||||
const body = options.body()
|
heightAnim?.stop()
|
||||||
if (!content || !body) return
|
fadeAnim?.stop()
|
||||||
heightAnim?.stop()
|
if (reduce()) {
|
||||||
fadeAnim?.stop()
|
body.style.opacity = ""
|
||||||
const id = ++gen
|
body.style.filter = ""
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
content.style.display = ""
|
content.style.display = ""
|
||||||
content.style.height = "0px"
|
content.style.height = "auto"
|
||||||
body.style.opacity = "0"
|
options.onOpen?.()
|
||||||
body.style.filter = "blur(2px)"
|
|
||||||
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (gen !== id) return
|
|
||||||
const c = options.content()
|
|
||||||
if (!c) return
|
|
||||||
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
|
|
||||||
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
|
||||||
heightAnim.finished.then(
|
|
||||||
() => {
|
|
||||||
if (gen !== id) return
|
|
||||||
c.style.height = "auto"
|
|
||||||
options.onOpen?.()
|
|
||||||
},
|
|
||||||
() => {},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
content.style.height = "0px"
|
||||||
|
content.style.display = "none"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = ++gen
|
||||||
|
if (isOpen) {
|
||||||
|
content.style.display = ""
|
||||||
|
content.style.height = "0px"
|
||||||
|
body.style.opacity = "0"
|
||||||
|
body.style.filter = "blur(2px)"
|
||||||
|
fadeAnim = animate(body, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"] }, COLLAPSIBLE_SPRING)
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (gen !== id) return
|
||||||
|
const c = options.content()
|
||||||
|
if (!c) return
|
||||||
|
const h = options.measure?.() ?? Math.ceil(body.getBoundingClientRect().height)
|
||||||
|
heightAnim = animate(c, { height: ["0px", `${h}px`] }, COLLAPSIBLE_SPRING)
|
||||||
|
heightAnim.finished.then(
|
||||||
|
() => {
|
||||||
|
if (gen !== id) return
|
||||||
|
c.style.height = "auto"
|
||||||
|
options.onOpen?.()
|
||||||
|
},
|
||||||
|
() => {},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const h = content.getBoundingClientRect().height
|
const h = content.getBoundingClientRect().height
|
||||||
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
heightAnim = animate(content, { height: [`${h}px`, "0px"] }, COLLAPSIBLE_SPRING)
|
||||||
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
fadeAnim = animate(body, { opacity: [1, 0], filter: ["blur(0px)", "blur(2px)"] }, COLLAPSIBLE_SPRING)
|
||||||
heightAnim.finished.then(
|
heightAnim.finished.then(
|
||||||
() => {
|
() => {
|
||||||
if (gen !== id) return
|
if (gen !== id) return
|
||||||
content.style.display = "none"
|
content.style.display = "none"
|
||||||
},
|
},
|
||||||
() => {},
|
() => {},
|
||||||
)
|
)
|
||||||
},
|
}),
|
||||||
{ defer: true },
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
|
|
@ -181,7 +191,7 @@ export function useRowWipe(opts: {
|
||||||
ref: () => HTMLElement | undefined
|
ref: () => HTMLElement | undefined
|
||||||
seen: Set<string>
|
seen: Set<string>
|
||||||
}) {
|
}) {
|
||||||
const reduce = prefersReducedMotion
|
const reduce = useReducedMotion()
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const id = opts.id()
|
const id = opts.id()
|
||||||
|
|
@ -265,13 +275,14 @@ export function useToolFade(
|
||||||
const delay = options?.delay ?? 0
|
const delay = options?.delay ?? 0
|
||||||
const wipe = options?.wipe ?? false
|
const wipe = options?.wipe ?? false
|
||||||
const active = options?.animate !== false
|
const active = options?.animate !== false
|
||||||
|
const reduce = useReducedMotion()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (!active) return
|
if (!active) return
|
||||||
|
|
||||||
const el = ref()
|
const el = ref()
|
||||||
if (!el || typeof window === "undefined") return
|
if (!el || typeof window === "undefined") return
|
||||||
if (prefersReducedMotion()) return
|
if (reduce()) return
|
||||||
|
|
||||||
const mask =
|
const mask =
|
||||||
wipe &&
|
wipe &&
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
export * from "./use-filtered-list"
|
export * from "./use-filtered-list"
|
||||||
export * from "./create-auto-scroll"
|
export * from "./create-auto-scroll"
|
||||||
export * from "./use-element-height"
|
|
||||||
export * from "./use-reduced-motion"
|
export * from "./use-reduced-motion"
|
||||||
export * from "./use-page-visible"
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tracks an element's height via ResizeObserver.
|
|
||||||
* Returns a reactive signal that updates whenever the element resizes.
|
|
||||||
*/
|
|
||||||
export function useElementHeight(
|
|
||||||
ref: Accessor<HTMLElement | undefined> | (() => HTMLElement | undefined),
|
|
||||||
initial = 0,
|
|
||||||
): Accessor<number> {
|
|
||||||
const [height, setHeight] = createSignal(initial)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const el = ref()
|
|
||||||
if (!el) return
|
|
||||||
setHeight(el.getBoundingClientRect().height)
|
|
||||||
const observer = new ResizeObserver(() => {
|
|
||||||
setHeight(el.getBoundingClientRect().height)
|
|
||||||
})
|
|
||||||
observer.observe(el)
|
|
||||||
onCleanup(() => observer.disconnect())
|
|
||||||
})
|
|
||||||
|
|
||||||
return height
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { createSignal } from "solid-js"
|
|
||||||
|
|
||||||
export const pageVisible = /* @__PURE__ */ (() => {
|
|
||||||
const [visible, setVisible] = createSignal(true)
|
|
||||||
if (typeof document !== "undefined") {
|
|
||||||
const sync = () => setVisible(document.visibilityState !== "hidden")
|
|
||||||
sync()
|
|
||||||
document.addEventListener("visibilitychange", sync)
|
|
||||||
}
|
|
||||||
return visible
|
|
||||||
})()
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { createSignal } from "solid-js"
|
import { isHydrated } from "@solid-primitives/lifecycle"
|
||||||
|
import { createMediaQuery } from "@solid-primitives/media"
|
||||||
|
import { createHydratableSingletonRoot } from "@solid-primitives/rootless"
|
||||||
|
|
||||||
export const prefersReducedMotion = /* @__PURE__ */ (() => {
|
const query = "(prefers-reduced-motion: reduce)"
|
||||||
if (typeof window === "undefined") return () => false
|
|
||||||
const mql = window.matchMedia("(prefers-reduced-motion: reduce)")
|
export const useReducedMotion = createHydratableSingletonRoot(() => {
|
||||||
const [reduced, setReduced] = createSignal(mql.matches)
|
const value = createMediaQuery(query)
|
||||||
mql.addEventListener("change", () => setReduced(mql.matches))
|
return () => !isHydrated() || value()
|
||||||
return reduced
|
})
|
||||||
})()
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.2.21",
|
"version": "1.2.22",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue