mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 17:26:22 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4fe9fec0e | |||
| 43998b7cfd | |||
| 0e0f1c0b7f | |||
| 82334e2a49 | |||
| 27ecc46dc7 | |||
| 8c7c69c749 | |||
| 9b16d0b069 | |||
| ccc11dc92d | |||
| a47dabff22 | |||
| 1277ceb426 | |||
| 7b7335b7e9 | |||
| 7192fa8b7a | |||
| 4062b30409 | |||
| f77f5a343e | |||
| 985ee1e2ec | |||
| 83bee1e776 | |||
| 124714ca3a |
@@ -0,0 +1,132 @@
|
||||
# Real desktop renderer profile
|
||||
|
||||
## Scope
|
||||
|
||||
- Revision: `60e23fee161982d572fafbf6cc0df63ac8ac87cc`
|
||||
- Runtime: production Electron preview from the current `dev` build
|
||||
- Data: the live `opencode.db` used by Beta, queried read-only for targets active in the preceding 24 hours
|
||||
- Contents are never written to reports. Reports contain rank labels, counts, sizes, durations, and source locations only.
|
||||
- The database is shared by channels, so rows cannot be attributed exclusively to the Beta executable.
|
||||
|
||||
The final attributed run covered 26 active sessions: 7 roots and 19 child/subagent sessions. The selected root-session targets were:
|
||||
|
||||
| Rank | Serialized bytes in window | Messages | Parts | User turns |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| p50 | 13,211 | 6 | 14 | 3 |
|
||||
| p95/max | 7,665,253 | 421 | 1,883 | 51 |
|
||||
|
||||
The p95 and maximum were the same root session. The harness intentionally runs it twice, so those rows are warm/cold repetitions rather than independent samples.
|
||||
|
||||
## Confirmed main-thread work
|
||||
|
||||
### 1. Response parsing and synchronous continuation
|
||||
|
||||
The largest consistently attributed script entry starts at `Response.text.then` in `packages/sdk/js/src/v2/gen/client/client.gen.ts:171-172`. The client reads the complete body as text and calls synchronous `JSON.parse` on the renderer. The LoAF duration also includes the promise microtasks that synchronously ingest the parsed result and update the UI.
|
||||
|
||||
- Large-session navigation: 139-391 ms attributed to this entry point, including 38-88 ms forced style/layout.
|
||||
- History pagination: 388 ms attributed, including 64 ms forced style/layout.
|
||||
- Home: two response continuations took 77 ms and 61 ms, including 22 ms forced style/layout.
|
||||
|
||||
This is the first boundary to split or move off-thread. The measurement does not imply that all attributed time is `JSON.parse`; it includes downstream work in the same promise checkpoint.
|
||||
|
||||
### 2. Timeline measurement and scroll reconciliation
|
||||
|
||||
History loading was the heaviest repeatable workflow.
|
||||
|
||||
| Metric | Clean-run range |
|
||||
| --- | ---: |
|
||||
| Long tasks | 6-9 |
|
||||
| Total long-task time | 763-1,097 ms |
|
||||
| Worst long task | 138-309 ms |
|
||||
| Workflow time | 4.7-5.3 s |
|
||||
|
||||
The attributed run found:
|
||||
|
||||
- TanStack Virtual `ResizeObserver`: 173 ms, including 42 ms forced layout.
|
||||
- TanStack Virtual scroll handler: 160 ms.
|
||||
- `ScrollView` scroll handler at `packages/ui/src/components/scroll-view.tsx:173-202`: 65 ms, including 64 ms forced layout.
|
||||
- The timeline's virtualizer and resize anchoring are configured at `packages/app/src/pages/session/timeline/message-timeline.tsx:413-486`.
|
||||
|
||||
The next optimization target is the response-to-projection-to-measurement pipeline, especially reducing synchronous DOM reads and repeated resize/scroll reactions while prepending history.
|
||||
|
||||
### 3. Renderer-side Shiki WASM startup
|
||||
|
||||
A small-session navigation loaded `@shikijs/engine-oniguruma` in the renderer and spent 58-62 ms evaluating its WASM module. Session Markdown parsing/highlighting and Pierre highlighting have workers, but renderer-side Shiki/Pierre initialization is still reachable. The bundled path is created by `getSharedHighlighter(... preferredHighlighter: "shiki-wasm")` and should be audited separately from worker execution.
|
||||
|
||||
### 4. Home startup and geometry
|
||||
|
||||
Across clean runs, Home produced 3-4 long tasks, 277-394 ms total, with a 127-172 ms maximum.
|
||||
|
||||
Attribution showed:
|
||||
|
||||
- Main module evaluation: 137 ms.
|
||||
- Two response continuations: 77 ms and 61 ms.
|
||||
- 22 ms forced style/layout.
|
||||
- CPU samples in `ScrollView.updateThumb` and `home-scroll-controller.ts:56-69` (`getComputedStyle`, every header's `offsetTop`, and `scrollTop`).
|
||||
|
||||
### 5. Review opening
|
||||
|
||||
The current real workspace review was not a major hotspot. Clean runs ranged from zero to two long tasks, with a 0-106 ms maximum. The attributed run had a 35 ms delegated click handler and 11 ms forced style/layout. This corpus had one diff viewer and does not validate large-review behavior.
|
||||
|
||||
## Markdown renderer conclusion
|
||||
|
||||
No current real text part exceeded 19,466 characters in the initial corpus audit. In the desktop traces:
|
||||
|
||||
- Marked, KaTeX, and Shiki parsing did not appear as renderer CPU hotspots because they run in the Markdown worker.
|
||||
- Markdown worker response handling appeared as a 43 ms entry in one run.
|
||||
- Renderer `postMessage`, `innerHTML`, HTML parsing, sanitization-related DOM parsing, and token DOM updates appeared in low-single-digit samples and accumulated tens of milliseconds, but none was independently responsible for the observed long tasks.
|
||||
- Checksumming did not appear above the 1 ms CPU reporting threshold.
|
||||
|
||||
For this real 24-hour corpus, Markdown's remaining renderer stages are secondary to response ingestion and timeline layout. This does not establish safety for an exceptional multi-megabyte text part.
|
||||
|
||||
## Reviewed but not measured
|
||||
|
||||
The source audit also found scale-sensitive renderer paths that this real workflow did not exercise enough to assign runtime numbers:
|
||||
|
||||
- Terminal buffer serialization/restoration over up to 10,000 rows.
|
||||
- In-file search text-node scans, DOM `Range` creation, and match geometry.
|
||||
- Large file/diff preprocessing and virtualized file DOM work.
|
||||
- Command-palette filtering and unvirtualized result rendering.
|
||||
- Draft recursive serialization and blob hashing.
|
||||
- Generic persisted-state parse/merge/stringify.
|
||||
- Large review trees and large diff switching.
|
||||
|
||||
No synthetic data was introduced merely to force these paths. They remain candidates for a future profile when corresponding real data exists.
|
||||
|
||||
## Reproduce
|
||||
|
||||
From `packages/app`:
|
||||
|
||||
```powershell
|
||||
$env:OPENCODE_PROFILE_DIAGNOSTICS = "0"
|
||||
$env:OPENCODE_PROFILE_OUTPUT = "C:\tmp\opencode\markdown-profile-results\run"
|
||||
bun run e2e/performance/real-desktop-profile.ts
|
||||
```
|
||||
|
||||
The script builds and launches Electron, points it at the shared real database, enables Electron 42's `AlwaysLogLOAFURL` feature for custom-protocol script attribution, profiles the workflows, and terminates the complete process tree in `finally`.
|
||||
|
||||
For Chrome traces and a 1 ms CPU sampler:
|
||||
|
||||
```powershell
|
||||
$env:OPENCODE_PROFILE_DIAGNOSTICS = "1"
|
||||
$env:OPENCODE_PROFILE_OUTPUT = "C:\tmp\opencode\markdown-profile-results\diagnostic"
|
||||
bun run e2e/performance/real-desktop-profile.ts
|
||||
```
|
||||
|
||||
Diagnostic timing is perturbed by tracing and sampling. Use clean runs for blocking-time numbers and diagnostic runs only for attribution.
|
||||
|
||||
Useful outputs from this audit:
|
||||
|
||||
- `C:\tmp\opencode\markdown-profile-results\loaf-positions\renderer-profile.json`
|
||||
- `C:\tmp\opencode\markdown-profile-results\diagnostic\renderer-profile.json`
|
||||
- `C:\tmp\opencode\markdown-profile-results\diagnostic\traces`
|
||||
|
||||
## LoAF attribution
|
||||
|
||||
Electron custom protocols normally produce an empty `PerformanceLongAnimationFrameTiming.scripts` array. Electron 42 supports custom-protocol attribution behind `--enable-features=AlwaysLogLOAFURL`; the harness enables it through an environment-guarded `app.commandLine.appendSwitch` and verifies it with an 80 ms calibration callback.
|
||||
|
||||
References:
|
||||
|
||||
- https://developer.chrome.com/docs/web-platform/long-animation-frames
|
||||
- https://www.electronjs.org/docs/latest/api/command-line-switches#chromium-features-relevant-to-electron-apps
|
||||
- https://github.com/electron/electron/pull/49706
|
||||
@@ -0,0 +1,675 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import { chromium, type CDPSession, type Page } from "@playwright/test"
|
||||
import path from "node:path"
|
||||
import { startChromeTrace } from "./chrome-trace"
|
||||
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const desktop = path.join(root, "packages/desktop")
|
||||
const databasePath = process.env.OPENCODE_PROFILE_DB ?? "C:/Users/Lukem/.local/share/opencode/opencode.db"
|
||||
const output = process.env.OPENCODE_PROFILE_OUTPUT ?? "C:/tmp/opencode/markdown-profile-results"
|
||||
const cdpPort = process.env.OPENCODE_PROFILE_CDP_PORT ?? String(19_000 + (process.pid % 1_000))
|
||||
const endpoint = process.env.OPENCODE_PROFILE_CDP ?? `http://127.0.0.1:${cdpPort}`
|
||||
const diagnostics = process.env.OPENCODE_PROFILE_DIAGNOSTICS !== "0"
|
||||
const profileCPU = process.env.OPENCODE_PROFILE_CPU === "1"
|
||||
const windowEnd = Number(process.env.OPENCODE_PROFILE_WINDOW_END ?? Date.now())
|
||||
const windowStart = windowEnd - 24 * 60 * 60 * 1_000
|
||||
|
||||
type Target = {
|
||||
label: "p50" | "p95" | "max"
|
||||
id: string
|
||||
directory: string
|
||||
title: string
|
||||
bytes: number
|
||||
messages: number
|
||||
parts: number
|
||||
userTurns: number
|
||||
}
|
||||
|
||||
type ProbeResult = {
|
||||
longTasks: number[]
|
||||
animationFrames: {
|
||||
duration: number
|
||||
blockingDuration: number
|
||||
forcedStyleAndLayoutDuration: number
|
||||
scripts: {
|
||||
function: string
|
||||
source: string
|
||||
position: number
|
||||
invoker: string
|
||||
invokerType: string
|
||||
duration: number
|
||||
forcedStyleAndLayoutDuration: number
|
||||
}[]
|
||||
}[]
|
||||
frameGaps: number[]
|
||||
responseText: { url: string; duration: number }[]
|
||||
}
|
||||
|
||||
const targets = loadTargets()
|
||||
const typingText = loadTypingText(targets.find((target) => target.label === "max")!)
|
||||
await Bun.$`mkdir -p ${output}`
|
||||
process.env.OPENCODE_PERFORMANCE_TRACE_DIR = path.join(output, "traces")
|
||||
process.env.OPENCODE_PERFORMANCE_RUN_ID = new Date(windowEnd).toISOString().replace(/[:.]/g, "-")
|
||||
|
||||
if (process.env.OPENCODE_PROFILE_SKIP_BUILD !== "1") await run(["bun", "run", "build"], desktop)
|
||||
|
||||
const child = Bun.spawn(["bun", "run", "preview"], {
|
||||
cwd: desktop,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCODE_DB: databasePath,
|
||||
OPENCODE_CHANNEL: "dev",
|
||||
OPENCODE_PROFILE_LOAF: "1",
|
||||
OPENCODE_PROFILE_CDP_PORT: cdpPort,
|
||||
OPENCODE_PROFILE_USER_DATA:
|
||||
process.env.OPENCODE_PROFILE_USER_DATA ?? "C:/tmp/opencode/markdown-profile-user-data",
|
||||
},
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const stdout = drain(child.stdout)
|
||||
const stderr = drain(child.stderr)
|
||||
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | undefined
|
||||
|
||||
try {
|
||||
await waitForCDP()
|
||||
browser = await chromium.connectOverCDP(endpoint)
|
||||
const page = await waitForRenderer(browser)
|
||||
await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 })
|
||||
await installProbe(page)
|
||||
await page.evaluate(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
const results = []
|
||||
results.push(await profileHome(page))
|
||||
results.push(await profileCalibration(page))
|
||||
for (const target of targets) {
|
||||
results.push(await profileSession(page, target))
|
||||
}
|
||||
results.push(await profileComposer(page))
|
||||
results.push(await profileHistoryBoundary(page, targets.find((target) => target.label === "max")!))
|
||||
const review = await profileReview(page)
|
||||
if (review) results.push(review)
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
source: "real-opencode-db",
|
||||
diagnostics,
|
||||
profileCPU,
|
||||
window: {
|
||||
start: new Date(windowStart).toISOString(),
|
||||
end: new Date(windowEnd).toISOString(),
|
||||
},
|
||||
revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(),
|
||||
targets: targets.map(({ id: _, directory: __, title: ___, ...target }) => target),
|
||||
results,
|
||||
}
|
||||
const file = path.join(output, "renderer-profile.json")
|
||||
await Bun.write(file, JSON.stringify(report, null, 2))
|
||||
console.log(`PROFILE_REPORT ${file}`)
|
||||
console.log(JSON.stringify(report, null, 2))
|
||||
} finally {
|
||||
await browser?.close().catch(() => {})
|
||||
await killTree(child.pid)
|
||||
await Promise.allSettled([stdout, stderr])
|
||||
}
|
||||
|
||||
async function profileCalibration(page: Page) {
|
||||
await resetProbe(page)
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(function opencodeProfileCalibration() {
|
||||
const end = performance.now() + 80
|
||||
while (performance.now() < end) {
|
||||
// Deliberate benchmark-only main-thread block.
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(resolve, 100))
|
||||
})
|
||||
}),
|
||||
)
|
||||
const metrics = await collectProbe(page)
|
||||
return { name: "attribution-calibration", ...summarizeProbe(metrics) }
|
||||
}
|
||||
|
||||
function loadTargets() {
|
||||
const database = new Database(databasePath, { readonly: true })
|
||||
database.run("PRAGMA query_only = ON")
|
||||
const sessions = database
|
||||
.query(
|
||||
`SELECT id, directory, title
|
||||
FROM session AS candidate
|
||||
WHERE parent_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM message
|
||||
WHERE session_id = candidate.id AND time_created >= ? AND time_created < ?
|
||||
)`,
|
||||
)
|
||||
.all(windowStart, windowEnd) as { id: string; directory: string; title: string }[]
|
||||
const messageRows = database.query(
|
||||
`SELECT id, data
|
||||
FROM message
|
||||
WHERE session_id = ? AND time_created >= ? AND time_created < ?
|
||||
ORDER BY time_created, id`,
|
||||
)
|
||||
const partRows = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`)
|
||||
const ranked = sessions
|
||||
.map((session) => {
|
||||
const messages = messageRows.all(session.id, windowStart, windowEnd) as { id: string; data: string }[]
|
||||
const parts = messages.flatMap((message) => partRows.all(message.id) as { data: string }[])
|
||||
return {
|
||||
...session,
|
||||
bytes:
|
||||
messages.reduce((sum, message) => sum + Buffer.byteLength(message.data), 0) +
|
||||
parts.reduce((sum, part) => sum + Buffer.byteLength(part.data), 0),
|
||||
messages: messages.length,
|
||||
parts: parts.length,
|
||||
userTurns: messages.filter((message) => JSON.parse(message.data).role === "user").length,
|
||||
}
|
||||
})
|
||||
.filter((session) => session.messages > 0)
|
||||
.sort((a, b) => a.bytes - b.bytes || a.id.localeCompare(b.id))
|
||||
database.close()
|
||||
if (ranked.length === 0) throw new Error("No sessions found in the profile window")
|
||||
const select = (label: Target["label"], percentile: number) => ({
|
||||
label,
|
||||
...ranked[Math.max(0, Math.ceil(ranked.length * percentile) - 1)]!,
|
||||
})
|
||||
return [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[]
|
||||
}
|
||||
|
||||
function loadTypingText(target: Target) {
|
||||
const database = new Database(databasePath, { readonly: true })
|
||||
database.run("PRAGMA query_only = ON")
|
||||
const messages = database
|
||||
.query(
|
||||
`SELECT id, data
|
||||
FROM message
|
||||
WHERE session_id = ? AND time_created >= ? AND time_created < ?
|
||||
ORDER BY time_created, id`,
|
||||
)
|
||||
.all(target.id, windowStart, windowEnd) as { id: string; data: string }[]
|
||||
const parts = database.query(`SELECT data FROM part WHERE message_id = ? ORDER BY id`)
|
||||
const text = messages
|
||||
.filter((message) => JSON.parse(message.data).role === "user")
|
||||
.flatMap((message) =>
|
||||
(parts.all(message.id) as { data: string }[]).flatMap((part) => {
|
||||
const data = JSON.parse(part.data)
|
||||
return data.type === "text" && typeof data.text === "string" ? [data.text] : []
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => b.length - a.length)[0]
|
||||
database.close()
|
||||
if (!text) throw new Error("No real user prompt found for composer profiling")
|
||||
return text
|
||||
}
|
||||
|
||||
async function profileHome(page: Page) {
|
||||
const stopTrace = diagnostics ? await startChromeTrace(page, "home") : undefined
|
||||
const cpu = await startCPUProfile(page)
|
||||
const started = performance.now()
|
||||
await setDesktopRoute(page, "/")
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||
await page.waitForSelector('[data-component="home-session-row"]', { timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
const elapsedMs = performance.now() - started
|
||||
const metrics = await collectProbe(page)
|
||||
const dom = await page.evaluate(() => ({
|
||||
elements: document.getElementsByTagName("*").length,
|
||||
timelineRows: document.querySelectorAll("[data-timeline-row]").length,
|
||||
messageRows: document.querySelectorAll("[data-message-id]").length,
|
||||
markdownRoots: document.querySelectorAll('[data-component="markdown"]').length,
|
||||
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||
}))
|
||||
return {
|
||||
name: "home",
|
||||
elapsedMs,
|
||||
...summarizeProbe(metrics),
|
||||
dom,
|
||||
cpu: await cpu.stop(),
|
||||
trace: await stopTrace?.(),
|
||||
}
|
||||
}
|
||||
|
||||
async function profileSession(page: Page, target: Target) {
|
||||
await setDesktopRoute(page, "/")
|
||||
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
|
||||
await page.waitForSelector('[data-component="home-session-row"]', { timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
await resetProbe(page)
|
||||
const stopTrace = diagnostics ? await startChromeTrace(page, `session-${target.label}`) : undefined
|
||||
const cpu = await startCPUProfile(page)
|
||||
const started = performance.now()
|
||||
await page.evaluate((title) => {
|
||||
const button = [...document.querySelectorAll<HTMLButtonElement>('[data-component="home-session-row"]')].find(
|
||||
(element) => element.textContent?.includes(title),
|
||||
)
|
||||
if (!button) throw new Error("Ranked root session was not found on Home")
|
||||
button.click()
|
||||
}, target.title)
|
||||
await page.waitForSelector('[data-component="prompt-input"]', { timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
const elapsedMs = performance.now() - started
|
||||
const metrics = await collectProbe(page)
|
||||
const dom = await page.evaluate(() => ({
|
||||
elements: document.getElementsByTagName("*").length,
|
||||
timelineRows: document.querySelectorAll("[data-timeline-row]").length,
|
||||
messageRows: document.querySelectorAll("[data-message-id]").length,
|
||||
markdownRoots: document.querySelectorAll('[data-component="markdown"]').length,
|
||||
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||
}))
|
||||
return {
|
||||
name: `session-${target.label}`,
|
||||
context: {
|
||||
serializedBytes: target.bytes,
|
||||
messages: target.messages,
|
||||
parts: target.parts,
|
||||
userTurns: target.userTurns,
|
||||
},
|
||||
elapsedMs,
|
||||
...summarizeProbe(metrics),
|
||||
dom,
|
||||
cpu: await cpu.stop(),
|
||||
trace: await stopTrace?.(),
|
||||
}
|
||||
}
|
||||
|
||||
async function profileComposer(page: Page) {
|
||||
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]').first()
|
||||
await editor.click()
|
||||
await page.keyboard.press("Control+A")
|
||||
await page.keyboard.press("Backspace")
|
||||
const printable = [...typingText].filter(
|
||||
(character) => character !== "\r" && character !== "\n" && character !== "\t",
|
||||
)
|
||||
const measured = printable.slice(-120).join("")
|
||||
const prefix = printable.slice(0, -measured.length).join("")
|
||||
if (prefix) await page.keyboard.insertText(prefix)
|
||||
await waitForQuietDOM(page)
|
||||
await resetProbe(page)
|
||||
const stopTrace = diagnostics ? await startChromeTrace(page, "composer-typing") : undefined
|
||||
const cpu = await startCPUProfile(page)
|
||||
const durations: number[] = []
|
||||
for (const character of measured) {
|
||||
const started = performance.now()
|
||||
await page.keyboard.type(character)
|
||||
durations.push(performance.now() - started)
|
||||
}
|
||||
await waitForQuietDOM(page)
|
||||
const metrics = await collectProbe(page)
|
||||
await page.keyboard.press("Control+A")
|
||||
await page.keyboard.press("Backspace")
|
||||
return {
|
||||
name: "composer-typing",
|
||||
context: { promptCharacters: printable.length, measuredCharacters: measured.length },
|
||||
typing: {
|
||||
totalMs: sum(durations),
|
||||
meanMs: sum(durations) / durations.length,
|
||||
p50Ms: percentile(durations, 0.5),
|
||||
p95Ms: percentile(durations, 0.95),
|
||||
maxMs: Math.max(...durations),
|
||||
},
|
||||
...summarizeProbe(metrics),
|
||||
cpu: await cpu.stop(),
|
||||
trace: await stopTrace?.(),
|
||||
}
|
||||
}
|
||||
|
||||
async function profileHistoryBoundary(page: Page, target: Target) {
|
||||
await page.waitForSelector('[data-component="prompt-input"]', { timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
await resetProbe(page)
|
||||
const stopTrace = diagnostics ? await startChromeTrace(page, "session-max-history-boundary") : undefined
|
||||
const cpu = await startCPUProfile(page)
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first()
|
||||
const started = performance.now()
|
||||
let requests = 0
|
||||
const onResponse = (response: { url(): string }) => {
|
||||
if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++
|
||||
}
|
||||
page.on("response", onResponse)
|
||||
await scroller.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: -10_000, bubbles: true }))
|
||||
element.dispatchEvent(new Event("scroll", { bubbles: true }))
|
||||
})
|
||||
const timeout = Date.now() + 60_000
|
||||
while (requests === 0 && Date.now() < timeout) await page.waitForTimeout(50)
|
||||
if (requests === 0) throw new Error("History boundary did not request a page")
|
||||
await waitForQuietDOM(page)
|
||||
page.off("response", onResponse)
|
||||
const elapsedMs = performance.now() - started
|
||||
const metrics = await collectProbe(page)
|
||||
return {
|
||||
name: "session-max-history-boundary",
|
||||
context: {
|
||||
serializedBytes: target.bytes,
|
||||
messages: target.messages,
|
||||
parts: target.parts,
|
||||
userTurns: target.userTurns,
|
||||
},
|
||||
elapsedMs,
|
||||
messageRequests: requests,
|
||||
...summarizeProbe(metrics),
|
||||
cpu: await cpu.stop(),
|
||||
trace: await stopTrace?.(),
|
||||
}
|
||||
}
|
||||
|
||||
async function profileReview(page: Page) {
|
||||
const button = page.getByRole("button", { name: "Toggle review" })
|
||||
if (!(await button.isVisible().catch(() => false))) return
|
||||
const panel = page.locator("#review-panel")
|
||||
if (await panel.isVisible().catch(() => false)) {
|
||||
await button.click()
|
||||
await panel.waitFor({ state: "hidden", timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
}
|
||||
await resetProbe(page)
|
||||
const stopTrace = diagnostics ? await startChromeTrace(page, "review-open") : undefined
|
||||
const cpu = await startCPUProfile(page)
|
||||
const started = performance.now()
|
||||
await button.click()
|
||||
await panel.waitFor({ state: "visible", timeout: 60_000 })
|
||||
await waitForQuietDOM(page)
|
||||
const elapsedMs = performance.now() - started
|
||||
const metrics = await collectProbe(page)
|
||||
const dom = await page.evaluate(() => ({
|
||||
elements: document.getElementsByTagName("*").length,
|
||||
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
|
||||
diffLines: document.querySelectorAll("[data-line]").length,
|
||||
}))
|
||||
return {
|
||||
name: "review-open",
|
||||
elapsedMs,
|
||||
...summarizeProbe(metrics),
|
||||
dom,
|
||||
cpu: await cpu.stop(),
|
||||
trace: await stopTrace?.(),
|
||||
}
|
||||
}
|
||||
|
||||
async function installProbe(page: Page) {
|
||||
await page.addInitScript((attributeResponses) => {
|
||||
const state = {
|
||||
longTasks: [] as number[],
|
||||
animationFrames: [] as ProbeResult["animationFrames"],
|
||||
frameGaps: [] as number[],
|
||||
responseText: [] as ProbeResult["responseText"],
|
||||
}
|
||||
;(window as Window & { __opencodeRendererProfile?: typeof state }).__opencodeRendererProfile = state
|
||||
if (PerformanceObserver.supportedEntryTypes.includes("longtask")) {
|
||||
new PerformanceObserver((list) =>
|
||||
state.longTasks.push(...list.getEntries().map((entry) => entry.duration)),
|
||||
).observe({
|
||||
type: "longtask",
|
||||
})
|
||||
}
|
||||
if (PerformanceObserver.supportedEntryTypes.includes("long-animation-frame")) {
|
||||
new PerformanceObserver((list) =>
|
||||
state.animationFrames.push(
|
||||
...list.getEntries().map((entry) => {
|
||||
const frame = entry as PerformanceEntry & {
|
||||
blockingDuration: number
|
||||
scripts?: {
|
||||
duration: number
|
||||
forcedStyleAndLayoutDuration?: number
|
||||
sourceFunctionName?: string
|
||||
sourceURL?: string
|
||||
sourceCharPosition?: number
|
||||
invoker?: string
|
||||
invokerType?: string
|
||||
}[]
|
||||
}
|
||||
return {
|
||||
duration: frame.duration,
|
||||
blockingDuration: frame.blockingDuration,
|
||||
forcedStyleAndLayoutDuration:
|
||||
frame.scripts?.reduce((sum, script) => sum + (script.forcedStyleAndLayoutDuration ?? 0), 0) ?? 0,
|
||||
scripts:
|
||||
frame.scripts?.map((script) => ({
|
||||
function: script.sourceFunctionName || "(anonymous)",
|
||||
source: script.sourceURL?.split("/").at(-1) || "(document)",
|
||||
position: script.sourceCharPosition ?? -1,
|
||||
invoker: script.invoker ?? "(unknown)",
|
||||
invokerType: script.invokerType ?? "(unknown)",
|
||||
duration: script.duration,
|
||||
forcedStyleAndLayoutDuration: script.forcedStyleAndLayoutDuration ?? 0,
|
||||
})) ?? [],
|
||||
}
|
||||
}),
|
||||
),
|
||||
).observe({ type: "long-animation-frame" })
|
||||
}
|
||||
let previous = performance.now()
|
||||
const frame = (now: number) => {
|
||||
const gap = now - previous
|
||||
if (gap > 20) state.frameGaps.push(gap)
|
||||
previous = now
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
if (attributeResponses) {
|
||||
const responseText = Response.prototype.text
|
||||
Response.prototype.text = function () {
|
||||
const started = performance.now()
|
||||
const url = this.url
|
||||
return responseText.call(this).then((text) => {
|
||||
state.responseText.push({ url, duration: performance.now() - started })
|
||||
return text
|
||||
})
|
||||
}
|
||||
}
|
||||
}, process.env.OPENCODE_PROFILE_RESPONSE_URLS === "1")
|
||||
}
|
||||
|
||||
async function resetProbe(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const state = (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile
|
||||
if (!state) return
|
||||
state.longTasks.length = 0
|
||||
state.animationFrames.length = 0
|
||||
state.frameGaps.length = 0
|
||||
state.responseText.length = 0
|
||||
})
|
||||
}
|
||||
|
||||
async function collectProbe(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!,
|
||||
)
|
||||
}
|
||||
|
||||
function summarizeProbe(probe: ProbeResult) {
|
||||
const scripts = new Map<
|
||||
string,
|
||||
{
|
||||
function: string
|
||||
source: string
|
||||
position: number
|
||||
invoker: string
|
||||
invokerType: string
|
||||
durationMs: number
|
||||
forcedStyleAndLayoutMs: number
|
||||
}
|
||||
>()
|
||||
probe.animationFrames
|
||||
.flatMap((frame) => frame.scripts)
|
||||
.forEach((script) => {
|
||||
const key = `${script.source}:${script.position}:${script.invoker}`
|
||||
const current = scripts.get(key) ?? {
|
||||
function: script.function,
|
||||
source: script.source,
|
||||
position: script.position,
|
||||
invoker: script.invoker,
|
||||
invokerType: script.invokerType,
|
||||
durationMs: 0,
|
||||
forcedStyleAndLayoutMs: 0,
|
||||
}
|
||||
current.durationMs += script.duration
|
||||
current.forcedStyleAndLayoutMs += script.forcedStyleAndLayoutDuration
|
||||
scripts.set(key, current)
|
||||
})
|
||||
return {
|
||||
longTasks: {
|
||||
count: probe.longTasks.length,
|
||||
totalMs: sum(probe.longTasks),
|
||||
maxMs: Math.max(0, ...probe.longTasks),
|
||||
},
|
||||
longAnimationFrames: {
|
||||
count: probe.animationFrames.length,
|
||||
totalBlockingMs: sum(probe.animationFrames.map((frame) => frame.blockingDuration)),
|
||||
maxDurationMs: Math.max(0, ...probe.animationFrames.map((frame) => frame.duration)),
|
||||
forcedStyleAndLayoutMs: sum(probe.animationFrames.map((frame) => frame.forcedStyleAndLayoutDuration)),
|
||||
scripts: [...scripts.values()].sort((a, b) => b.durationMs - a.durationMs).slice(0, 15),
|
||||
},
|
||||
frameGaps: {
|
||||
count: probe.frameGaps.length,
|
||||
maxMs: Math.max(0, ...probe.frameGaps),
|
||||
},
|
||||
responseText: probe.responseText
|
||||
.map((item) => ({
|
||||
path: (() => {
|
||||
try {
|
||||
return new URL(item.url).pathname
|
||||
} catch {
|
||||
return item.url
|
||||
}
|
||||
})(),
|
||||
durationMs: item.duration,
|
||||
}))
|
||||
.sort((a, b) => b.durationMs - a.durationMs),
|
||||
}
|
||||
}
|
||||
|
||||
async function startCPUProfile(page: Page) {
|
||||
if (!profileCPU) return { stop: async () => [] }
|
||||
const session = await page.context().newCDPSession(page)
|
||||
await session.send("Profiler.enable")
|
||||
await session.send("Profiler.setSamplingInterval", { interval: 1_000 })
|
||||
await session.send("Profiler.start")
|
||||
return {
|
||||
async stop() {
|
||||
const result = await session.send("Profiler.stop")
|
||||
await session.detach()
|
||||
const self = new Map<number, number>()
|
||||
result.profile.samples?.forEach((id, index) => {
|
||||
self.set(id, (self.get(id) ?? 0) + (result.profile.timeDeltas?.[index] ?? 0) / 1_000)
|
||||
})
|
||||
return result.profile.nodes
|
||||
.map((node) => ({
|
||||
function: node.callFrame.functionName || "(anonymous)",
|
||||
source: sourceName(node.callFrame.url),
|
||||
line: node.callFrame.lineNumber + 1,
|
||||
selfMs: self.get(node.id) ?? 0,
|
||||
}))
|
||||
.filter((node) => node.selfMs >= 1)
|
||||
.sort((a, b) => b.selfMs - a.selfMs)
|
||||
.slice(0, 40)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function setDesktopRoute(page: Page, route: string) {
|
||||
await page.evaluate(async (value) => {
|
||||
const api = window.api as typeof window.api & { getWindowID?: () => Promise<string> }
|
||||
const id = (await api.getWindowID?.()) ?? "browser"
|
||||
localStorage.setItem(`opencode.desktop.window.${id}.last-active-url`, value)
|
||||
}, route)
|
||||
}
|
||||
|
||||
async function waitForQuietDOM(page: Page) {
|
||||
await page.evaluate(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
let timer = setTimeout(done, 750)
|
||||
const observer = new MutationObserver(() => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(done, 750)
|
||||
})
|
||||
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
||||
function done() {
|
||||
observer.disconnect()
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForCDP() {
|
||||
const timeout = Date.now() + 5 * 60_000
|
||||
while (Date.now() < timeout) {
|
||||
const ready = await fetch(`${endpoint}/json/version`)
|
||||
.then((response) => response.ok)
|
||||
.catch(() => false)
|
||||
if (ready) return
|
||||
if (child.exitCode !== null)
|
||||
throw new Error(
|
||||
`Desktop exited before CDP was ready (${child.exitCode})\n${await stdout}\n${await stderr}`,
|
||||
)
|
||||
await Bun.sleep(250)
|
||||
}
|
||||
throw new Error("Timed out waiting for desktop CDP")
|
||||
}
|
||||
|
||||
async function waitForRenderer(browser: Awaited<ReturnType<typeof chromium.connectOverCDP>>) {
|
||||
const timeout = Date.now() + 60_000
|
||||
while (Date.now() < timeout) {
|
||||
const page = browser
|
||||
.contexts()
|
||||
.flatMap((context) => context.pages())
|
||||
.find((candidate) => candidate.url().startsWith("oc://renderer"))
|
||||
if (page) return page
|
||||
await Bun.sleep(100)
|
||||
}
|
||||
throw new Error("Desktop renderer target was not found")
|
||||
}
|
||||
|
||||
async function run(command: string[], cwd: string) {
|
||||
const child = Bun.spawn(command, { cwd, env: processEnv(), stdout: "inherit", stderr: "inherit" })
|
||||
const code = await child.exited
|
||||
if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`)
|
||||
}
|
||||
|
||||
function processEnv() {
|
||||
return { ...process.env, OPENCODE_DB: databasePath, OPENCODE_CHANNEL: "dev" }
|
||||
}
|
||||
|
||||
async function drain(stream: ReadableStream<Uint8Array>) {
|
||||
const decoder = new TextDecoder()
|
||||
let output = ""
|
||||
for await (const chunk of stream) output = (output + decoder.decode(chunk, { stream: true })).slice(-50_000)
|
||||
return output + decoder.decode()
|
||||
}
|
||||
|
||||
async function killTree(pid: number) {
|
||||
if (process.platform !== "win32") {
|
||||
process.kill(pid, "SIGTERM")
|
||||
return
|
||||
}
|
||||
const child = Bun.spawn(["taskkill", "/pid", String(pid), "/T", "/F"], { stdout: "ignore", stderr: "ignore" })
|
||||
await child.exited
|
||||
}
|
||||
|
||||
function sourceName(value: string) {
|
||||
if (!value) return "(native)"
|
||||
try {
|
||||
return new URL(value).pathname.split("/").at(-1) || "(document)"
|
||||
} catch {
|
||||
return path.basename(value)
|
||||
}
|
||||
}
|
||||
|
||||
function sum(values: number[]) {
|
||||
return values.reduce((total, value) => total + value, 0)
|
||||
}
|
||||
|
||||
function percentile(values: number[], quantile: number) {
|
||||
return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const initialPageSize = 20
|
||||
const historyPageSize = 200
|
||||
const historyPageSize = 50
|
||||
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as Sentry from "@sentry/solid"
|
||||
import { I18nProvider } from "@opencode-ai/ui/context"
|
||||
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
|
||||
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Font } from "@opencode-ai/ui/font"
|
||||
import { Splash } from "@opencode-ai/ui/logo"
|
||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||
@@ -58,18 +57,24 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { WslServersProvider } from "@/wsl/context"
|
||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import LegacyLayout from "@/pages/layout"
|
||||
import NewLayout from "@/pages/layout-new"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { useCheckServerHealth } from "./utils/server-health"
|
||||
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { createSessionLineage } from "@/pages/session/session-lineage"
|
||||
|
||||
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
|
||||
import { NewHome } from "@/pages/home"
|
||||
import { LegacyHome } from "@/pages/home/legacy-home"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
const NewLayout = lazy(() => import("@/pages/layout-new"))
|
||||
const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome })))
|
||||
const LegacyLayout = lazy(() => import("@/pages/layout"))
|
||||
const LegacyHome = lazy(() => import("@/pages/home/legacy-home").then((module) => ({ default: module.LegacyHome })))
|
||||
const File = lazy(() => import("@opencode-ai/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const SessionPage = lazy(() => import("@/pages/session").then((module) => ({ default: module.SessionPage })))
|
||||
const SessionRouteErrorBoundary = lazy(() =>
|
||||
import("@/pages/session").then((module) => ({ default: module.SessionRouteErrorBoundary })),
|
||||
)
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
import("@/pages/session").then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
|
||||
const SessionRoute = () => {
|
||||
const settings = useSettings()
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
|
||||
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
openDelay={0}
|
||||
delay="intent"
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
|
||||
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -53,12 +53,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
@@ -43,6 +44,7 @@ export const SettingsModels: Component = () => {
|
||||
const SettingsModelsContent: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
useServerSync()().loadProviders()
|
||||
|
||||
const list = useFilteredList<ModelItem>({
|
||||
items: (_filter) => models.list(),
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useModels } from "@/context/models"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
@@ -23,6 +24,7 @@ export const SettingsModelsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const models = useModels()
|
||||
const serverSdk = useServerSDK()
|
||||
useServerSync()().loadProviders()
|
||||
const [store, setStore] = persisted(
|
||||
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
|
||||
createStore({ collapsed: {} as Record<string, boolean> }),
|
||||
|
||||
@@ -353,6 +353,22 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
keybind: "mod+shift+t",
|
||||
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
||||
},
|
||||
{
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowLeft,ctrl+shift+tab`,
|
||||
hidden: true,
|
||||
onSelect: tabs.previous,
|
||||
},
|
||||
{
|
||||
id: `tab.next`,
|
||||
category: "tab",
|
||||
title: "",
|
||||
keybind: `mod+option+ArrowRight,ctrl+tab`,
|
||||
hidden: true,
|
||||
onSelect: tabs.next,
|
||||
},
|
||||
].filter((v) => v !== undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -152,12 +152,6 @@ export async function bootstrapGlobal(input: {
|
||||
queryClient: QueryClient
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK, input.protocol)),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol),
|
||||
),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
|
||||
@@ -524,17 +518,6 @@ export async function bootstrapDirectory(input: {
|
||||
input.queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol),
|
||||
)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -263,10 +263,13 @@ describe("createChildStoreManager", () => {
|
||||
manager.child("/project")
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[3]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
expect(queries[5]?.().enabled).toBe(true)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
|
||||
manager.enableProviders("/project")
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
|
||||
manager.child("/project", { bootstrap: false })
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
} finally {
|
||||
|
||||
@@ -47,6 +47,7 @@ export function createChildStoreManager(input: {
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const activeDirectories = new Set<string>()
|
||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const providerToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
const markKey = (key: DirectoryKey) => {
|
||||
if (!key) return
|
||||
@@ -122,6 +123,7 @@ export function createChildStoreManager(input: {
|
||||
mcpToggles.delete(key)
|
||||
activeDirectories.delete(key)
|
||||
activationToggles.delete(key)
|
||||
providerToggles.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
if (dispose) {
|
||||
dispose()
|
||||
@@ -187,6 +189,7 @@ export function createChildStoreManager(input: {
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||
const [providerEnabled, setProviderEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
|
||||
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
|
||||
@@ -194,7 +197,7 @@ export function createChildStoreManager(input: {
|
||||
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: instanceQueriesEnabled(),
|
||||
enabled: providerEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
@@ -206,7 +209,7 @@ export function createChildStoreManager(input: {
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return instanceQueriesEnabled() && !providerQuery.isLoading
|
||||
return providerEnabled() && !providerQuery.isLoading
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
@@ -263,6 +266,7 @@ export function createChildStoreManager(input: {
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
providerToggles.set(key, setProviderEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
if (!(init instanceof Promise)) return
|
||||
@@ -329,6 +333,12 @@ export function createChildStoreManager(input: {
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
function enableProviders(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
ensureChild(directory)
|
||||
providerToggles.get(key)?.(true)
|
||||
}
|
||||
|
||||
// Passive Home/project metadata reads must not initialize the directory.
|
||||
// A real directory access enables these queries once for the store lifetime.
|
||||
// TODO(v2): After Home switches to v2.project.list and root-filtered,
|
||||
@@ -387,6 +397,7 @@ export function createChildStoreManager(input: {
|
||||
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
|
||||
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
|
||||
disableMcp,
|
||||
enableProviders,
|
||||
disposeDirectory,
|
||||
runEviction,
|
||||
vcsCache,
|
||||
|
||||
@@ -22,6 +22,7 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index",
|
||||
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
|
||||
|
||||
type HomeSessionPage = { data?: V2SessionListResponse }
|
||||
type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } }
|
||||
|
||||
export async function loadHomeSessionIndex(
|
||||
list: (
|
||||
@@ -31,7 +32,30 @@ export async function loadHomeSessionIndex(
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: SessionV2Info[] = []
|
||||
return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal)
|
||||
}
|
||||
|
||||
export async function loadProjectedHomeSessionIndex(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<ProjectedHomeSessionPage>,
|
||||
eventSequence = 0,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
|
||||
}
|
||||
|
||||
async function loadHomeSessionPages<T>(
|
||||
list: (
|
||||
input: { limit: number; order: "desc"; cursor?: string },
|
||||
options: { signal?: AbortSignal },
|
||||
) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>,
|
||||
project: (sessions: T[]) => Session[],
|
||||
eventSequence: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const data: T[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
for (;;) {
|
||||
@@ -46,7 +70,7 @@ export async function loadHomeSessionIndex(
|
||||
const page = response.data!
|
||||
data.push(...page.data)
|
||||
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
|
||||
return { sessions: parseHomeSessionIndex(data), eventSequence }
|
||||
return { sessions: project(data), eventSequence }
|
||||
cursor = page.cursor.next
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import { useGlobal } from "./global"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
|
||||
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat"
|
||||
import { decodeVcsDiff } from "@/utils/vcs-diff-decoder"
|
||||
import { decodeSessionList } from "./session-message-decoder"
|
||||
|
||||
const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
@@ -346,7 +348,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
|
||||
throwOnError: true,
|
||||
directory,
|
||||
})
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
|
||||
const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList })
|
||||
|
||||
return {
|
||||
server,
|
||||
@@ -432,6 +434,8 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
|
||||
current: serverSDK.currentApi,
|
||||
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
|
||||
directory,
|
||||
decodeVcsDiff,
|
||||
decodeSessionList,
|
||||
}),
|
||||
event: emitter,
|
||||
get url() {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { normalizeSessionMessages } from "@/utils/session-message"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
|
||||
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||
|
||||
type MessageApi = ServerApi["message"]
|
||||
|
||||
@@ -29,10 +30,16 @@ const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id)
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const initialMessagePageSize = 20
|
||||
const historyMessagePageSize = 200
|
||||
const historyMessagePageSize = 50
|
||||
const sessionInfoLimit = 2_048
|
||||
const emptyIDs: ReadonlySet<string> = new Set()
|
||||
|
||||
function yieldToMain() {
|
||||
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
|
||||
if (scheduler) return scheduler.yield()
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||
const boundary = source.find(
|
||||
(message) =>
|
||||
@@ -183,7 +190,11 @@ function reconcileFetched<T extends { id: string }>(
|
||||
return [...result.values()].sort((a, b) => cmp(a.id, b.id))
|
||||
}
|
||||
|
||||
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
|
||||
type ServerSessionOptions = {
|
||||
retry?: typeof retry
|
||||
protocol?: Promise<"v1" | "v2">
|
||||
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
|
||||
}
|
||||
|
||||
export function createServerSession(
|
||||
client: OpencodeClient,
|
||||
@@ -552,6 +563,7 @@ export function createServerSession(
|
||||
if (!response.data.length) break
|
||||
}
|
||||
const response = pages.at(-1)!
|
||||
await yieldToMain()
|
||||
const source = pages.flatMap((page) => page.data).toReversed()
|
||||
const normalized = normalizeSessionMessages(sessionID, source)
|
||||
return {
|
||||
@@ -566,10 +578,21 @@ export function createServerSession(
|
||||
complete: response.data.length === 0,
|
||||
}
|
||||
}
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
const response = await (options?.retry ?? retry)(async () => {
|
||||
onAttempt?.()
|
||||
return client.session.messages({ sessionID, limit, before })
|
||||
if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before })
|
||||
const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" })
|
||||
if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer")
|
||||
return { response, decoded: await options.decodeMessages(response.data) }
|
||||
})
|
||||
await yieldToMain()
|
||||
if ("decoded" in response)
|
||||
return {
|
||||
...response.decoded,
|
||||
sourceMode: before ? ("older" as const) : ("latest" as const),
|
||||
cursor: response.response.response.headers.get("x-next-cursor") ?? undefined,
|
||||
complete: !response.response.response.headers.get("x-next-cursor"),
|
||||
}
|
||||
const items = (response.data ?? []).filter((item) => !!item?.info?.id)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createMemo, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
@@ -59,6 +59,7 @@ import type {
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { decodeSessionMessages } from "./session-message-decoder"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
@@ -226,6 +227,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
|
||||
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
|
||||
protocol: serverSDK.protocol,
|
||||
decodeMessages: decodeSessionMessages,
|
||||
})
|
||||
const queryOptionsApi = makeQueryOptionsApi(
|
||||
serverSDK.scope,
|
||||
@@ -235,31 +237,40 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
serverSDK.protocol,
|
||||
)
|
||||
|
||||
const [providersEnabled, setProvidersEnabled] = createSignal(false)
|
||||
const [backgroundEnabled, setBackgroundEnabled] = createSignal(false)
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
|
||||
queries: [
|
||||
{ ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() },
|
||||
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
|
||||
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
|
||||
],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() =>
|
||||
loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
seedActiveSessionStatuses(session, statuses)
|
||||
for (const sessionID of Object.keys(statuses)) {
|
||||
({
|
||||
...loadActiveSessionsQuery(serverSDK.scope, {
|
||||
active: async () => {
|
||||
if ((await serverSDK.protocol) === "v1") {
|
||||
const statuses = (await serverSDK.client.session.status()).data ?? {}
|
||||
seedActiveSessionStatuses(session, statuses)
|
||||
for (const sessionID of Object.keys(statuses)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(statuses).flatMap(([sessionID, status]) =>
|
||||
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
|
||||
),
|
||||
)
|
||||
}
|
||||
const active = await serverSDK.api.session.active()
|
||||
seedActiveSessionStatuses(session, active)
|
||||
for (const sessionID of Object.keys(active)) {
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
}
|
||||
return active
|
||||
},
|
||||
return active
|
||||
},
|
||||
}),
|
||||
enabled: backgroundEnabled(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -299,10 +310,36 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
let bootingRoot = false
|
||||
let eventFrame: number | undefined
|
||||
let eventTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let providerFrame: number | undefined
|
||||
let providerIdle: number | undefined
|
||||
let providerTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
onMount(() => {
|
||||
providerFrame = requestAnimationFrame(() => {
|
||||
providerFrame = requestAnimationFrame(() => {
|
||||
providerFrame = undefined
|
||||
providerTimer = setTimeout(() => {
|
||||
providerTimer = undefined
|
||||
if ("requestIdleCallback" in window) {
|
||||
providerIdle = requestIdleCallback(() => {
|
||||
setProvidersEnabled(true)
|
||||
setBackgroundEnabled(true)
|
||||
}, { timeout: 5_000 })
|
||||
return
|
||||
}
|
||||
setProvidersEnabled(true)
|
||||
setBackgroundEnabled(true)
|
||||
}, 10_000)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
|
||||
if (eventTimer !== undefined) clearTimeout(eventTimer)
|
||||
if (providerFrame !== undefined) cancelAnimationFrame(providerFrame)
|
||||
if (providerIdle !== undefined) cancelIdleCallback(providerIdle)
|
||||
if (providerTimer !== undefined) clearTimeout(providerTimer)
|
||||
})
|
||||
|
||||
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
|
||||
@@ -682,7 +719,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
enableProviders: children.enableProviders,
|
||||
queryOptions: queryOptionsApi,
|
||||
loadProviders: () => setProvidersEnabled(true),
|
||||
refreshProviders,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Message, Part, Session, SessionV2Info } from "@opencode-ai/sdk/v2/client"
|
||||
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||
|
||||
test("decodes and projects a legacy message page", () => {
|
||||
const info = {
|
||||
id: "message",
|
||||
sessionID: "session",
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", modelID: "model" },
|
||||
} as Message
|
||||
const part = {
|
||||
id: "part",
|
||||
sessionID: "session",
|
||||
messageID: info.id,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
} as Part
|
||||
const result = decodeLegacyMessagePage(new TextEncoder().encode(JSON.stringify([{ info, parts: [part] }])).buffer)
|
||||
|
||||
expect(result.session).toEqual([info])
|
||||
expect(result.part).toEqual([{ id: info.id, part: [part] }])
|
||||
expect(result.source).toEqual([{ id: info.id, type: "user", text: "hello", time: info.time }])
|
||||
})
|
||||
|
||||
test("decodes and projects a legacy session list", () => {
|
||||
const session = {
|
||||
id: "session",
|
||||
projectID: "project",
|
||||
directory: "/repo",
|
||||
title: "Session",
|
||||
version: "1",
|
||||
time: { created: 1, updated: 1 },
|
||||
} as Session
|
||||
const result = decodeLegacySessionList(new TextEncoder().encode(JSON.stringify([session])).buffer)
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({ id: session.id, title: session.title, location: { directory: "/repo" } }),
|
||||
])
|
||||
})
|
||||
|
||||
test("bounds Home sessions by directory before returning from the decoder", () => {
|
||||
const session = (id: string, directory: string, updated: number) =>
|
||||
({
|
||||
id,
|
||||
projectID: "project",
|
||||
location: { directory },
|
||||
subpath: "",
|
||||
title: id,
|
||||
time: { created: updated, updated },
|
||||
}) as SessionV2Info
|
||||
const page = {
|
||||
data: [session("old", "/repo", 1), session("new", "/repo", 2), session("other", "/other", 3)],
|
||||
cursor: {},
|
||||
}
|
||||
const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, {
|
||||
directories: ["/repo"],
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
expect(result.data.map((item) => item.id)).toEqual(["new"])
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { SessionInfo, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Message, Part, Session, V2SessionListResponse } from "@opencode-ai/sdk/v2/client"
|
||||
import { message as cleanMessage } from "@/utils/diffs"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { parseHomeSessionIndex } from "./global-sync/home-session-index"
|
||||
import { takeRecentSessions } from "./global-sync/session-trim"
|
||||
|
||||
export type DecodedLegacyMessagePage = {
|
||||
session: Message[]
|
||||
part: { id: string; part: Part[] }[]
|
||||
source: SessionMessageInfo[]
|
||||
}
|
||||
|
||||
export function decodeLegacyMessagePage(buffer: ArrayBuffer): DecodedLegacyMessagePage {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
const items = (text ? (JSON.parse(text) as { info?: Message; parts?: Part[] }[]) : []).filter(
|
||||
(item): item is { info: Message; parts: Part[] } => !!item.info?.id && Array.isArray(item.parts),
|
||||
)
|
||||
return {
|
||||
session: items.map((item) => cleanMessage(item.info)).sort((a, b) => compare(a.id, b.id)),
|
||||
part: items.map((item) => ({
|
||||
id: item.info.id,
|
||||
part: item.parts.filter((part) => !!part?.id).sort((a, b) => compare(a.id, b.id)),
|
||||
})),
|
||||
source: items
|
||||
.slice()
|
||||
.sort((a, b) => compare(a.info.id, b.info.id))
|
||||
.map((item) =>
|
||||
item.info.role === "user"
|
||||
? {
|
||||
id: item.info.id,
|
||||
type: "user" as const,
|
||||
text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
time: item.info.time,
|
||||
}
|
||||
: {
|
||||
id: item.info.id,
|
||||
type: "assistant" as const,
|
||||
agent: item.info.agent ?? item.info.mode,
|
||||
model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant },
|
||||
content: [],
|
||||
time: item.info.time,
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeLegacySessionList(buffer: ArrayBuffer) {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
return (text ? (JSON.parse(text) as Session[]) : []).map(legacySessionInfo)
|
||||
}
|
||||
|
||||
export function decodeHomeSessionPage(buffer: ArrayBuffer, options?: { directories: string[]; limit: number }) {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
const page = (text ? JSON.parse(text) : { data: [], cursor: {} }) as V2SessionListResponse
|
||||
const sessions = parseHomeSessionIndex(page.data)
|
||||
if (!options) return { data: sessions, cursor: page.cursor }
|
||||
const directories = new Set(options.directories.map(pathKey))
|
||||
return {
|
||||
data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))]
|
||||
.filter(([directory]) => directories.has(directory))
|
||||
.flatMap(([, items]) => takeRecentSessions(items, options.limit, Number.NEGATIVE_INFINITY)),
|
||||
cursor: page.cursor,
|
||||
}
|
||||
}
|
||||
|
||||
export function legacySessionInfo(session: Session): SessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID,
|
||||
agent: session.agent,
|
||||
model: session.model && {
|
||||
id: session.model.id,
|
||||
providerID: session.model.providerID,
|
||||
variant: session.model.variant,
|
||||
},
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: session.time,
|
||||
title: session.title,
|
||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||
subpath: session.path,
|
||||
revert: session.revert && {
|
||||
messageID: session.revert.messageID,
|
||||
partID: session.revert.partID,
|
||||
snapshot: session.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function compare(a: string, b: string) {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import SessionMessageDecoderWorkerUrl from "./session-message-decoder.worker.ts?worker&url"
|
||||
import type { DecodedLegacyMessagePage } from "./session-message-decode"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Response = { id: number; data?: unknown; error?: string }
|
||||
|
||||
let worker: Worker | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, { resolve: (value: unknown) => void; reject: (error: Error) => void }>()
|
||||
|
||||
export function decodeSessionMessages(buffer: ArrayBuffer) {
|
||||
return decode<DecodedLegacyMessagePage>("messages", buffer)
|
||||
}
|
||||
|
||||
export function decodeSessionList(buffer: ArrayBuffer) {
|
||||
return decode<SessionInfo[]>("sessions", buffer)
|
||||
}
|
||||
|
||||
export function decodeHomeSessionPage(buffer: ArrayBuffer, options: { directories: string[]; limit: number }) {
|
||||
return decode<{ data: Session[]; cursor: { next?: string } }>("homeSessions", buffer, options)
|
||||
}
|
||||
|
||||
function decode<T>(
|
||||
type: "messages" | "sessions" | "homeSessions",
|
||||
buffer: ArrayBuffer,
|
||||
options?: { directories: string[]; limit: number },
|
||||
) {
|
||||
const id = ++nextID
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
pending.set(id, { resolve: (value) => resolve(value as T), reject })
|
||||
getWorker().postMessage({ id, type, buffer, options }, [buffer])
|
||||
})
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
worker = new Worker(SessionMessageDecoderWorkerUrl, { type: "module" })
|
||||
worker.onmessage = (event: MessageEvent<Response>) => {
|
||||
const request = pending.get(event.data.id)
|
||||
if (!request) return
|
||||
pending.delete(event.data.id)
|
||||
if (event.data.error) {
|
||||
request.reject(new Error(event.data.error))
|
||||
return
|
||||
}
|
||||
request.resolve(event.data.data)
|
||||
}
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message)
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
return worker
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { decodeHomeSessionPage, decodeLegacyMessagePage, decodeLegacySessionList } from "./session-message-decode"
|
||||
|
||||
type DecoderRequest = {
|
||||
id: number
|
||||
type: "messages" | "sessions" | "homeSessions"
|
||||
buffer: ArrayBuffer
|
||||
options?: { directories: string[]; limit: number }
|
||||
}
|
||||
|
||||
self.onmessage = (event: MessageEvent<DecoderRequest>) => {
|
||||
try {
|
||||
self.postMessage({
|
||||
id: event.data.id,
|
||||
data: (() => {
|
||||
if (event.data.type === "messages") return decodeLegacyMessagePage(event.data.buffer)
|
||||
if (event.data.type === "sessions") return decodeLegacySessionList(event.data.buffer)
|
||||
return decodeHomeSessionPage(event.data.buffer, event.data.options)
|
||||
})(),
|
||||
})
|
||||
} catch (error) {
|
||||
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
function history(): TabHistory {
|
||||
return { stack: [], index: -1 }
|
||||
}
|
||||
|
||||
describe("tab history", () => {
|
||||
test("moves backward and forward through selected tabs", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const available = new Set(selected.stack)
|
||||
|
||||
const previous = previousTab(selected, available)
|
||||
expect(previous?.key).toBe("b")
|
||||
|
||||
const first = previousTab(previous!.state, available)
|
||||
expect(first?.key).toBe("a")
|
||||
|
||||
const next = nextTab(first!.state, available)
|
||||
expect(next?.key).toBe("b")
|
||||
})
|
||||
|
||||
test("replaces forward history after a new selection", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
const previous = previousTab(selected, new Set(selected.stack))
|
||||
const next = rememberTab(previous!.state, "d")
|
||||
|
||||
expect(next).toEqual({ stack: ["a", "b", "d"], index: 2 })
|
||||
expect(nextTab(next, new Set(next.stack))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips tabs that are no longer open", () => {
|
||||
const selected = ["a", "b", "c"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "c"]))?.key).toBe("a")
|
||||
})
|
||||
|
||||
test("skips a repeated current tab after closing the previous selection", () => {
|
||||
const selected = ["a", "b", "c", "b"].reduce(rememberTab, history())
|
||||
|
||||
expect(previousTab(selected, new Set(["a", "b"]))?.key).toBe("a")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
const MAX_TAB_HISTORY = 100
|
||||
|
||||
export type TabHistory = {
|
||||
stack: string[]
|
||||
index: number
|
||||
}
|
||||
|
||||
export function rememberTab(state: TabHistory, key: string): TabHistory {
|
||||
if (state.stack[state.index] === key) return state
|
||||
const stack = state.stack.slice(0, state.index + 1).concat(key).slice(-MAX_TAB_HISTORY)
|
||||
return { stack, index: stack.length - 1 }
|
||||
}
|
||||
|
||||
export function previousTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, -1, available)
|
||||
}
|
||||
|
||||
export function nextTab(state: TabHistory, available: Set<string>) {
|
||||
return move(state, 1, available)
|
||||
}
|
||||
|
||||
function move(state: TabHistory, offset: -1 | 1, available: Set<string>) {
|
||||
const current = state.stack[state.index]
|
||||
for (let index = state.index + offset; index >= 0 && index < state.stack.length; index += offset) {
|
||||
const key = state.stack[index]
|
||||
if (key && key !== current && available.has(key)) return { state: { ...state, index }, key }
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
||||
import { migrateTabs } from "./tab-migration"
|
||||
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -74,6 +75,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
const memory = createTabMemory(getOwner())
|
||||
|
||||
const closing = new Set<string>()
|
||||
let history: TabHistory = { stack: [], index: -1 }
|
||||
let recentWrite = 0
|
||||
let recentValue: string | undefined
|
||||
|
||||
@@ -148,10 +150,21 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
history = rememberTab(history, tabKey(tab))
|
||||
setRecentKey(tabKey(tab))
|
||||
navigate(href)
|
||||
}
|
||||
|
||||
const moveHistory = (direction: "previous" | "next") => {
|
||||
const available = new Set(store.map(tabKey))
|
||||
const result = direction === "previous" ? previousTab(history, available) : nextTab(history, available)
|
||||
if (!result) return
|
||||
const tab = store.find((item) => tabKey(item) === result.key)
|
||||
if (!tab) return
|
||||
history = result.state
|
||||
navigateTab(tab)
|
||||
}
|
||||
|
||||
const removeTab = (index: number) => {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
@@ -357,8 +370,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
select: navigateTab,
|
||||
remember(tab: Tab) {
|
||||
const key = tabKey(tab)
|
||||
history = rememberTab(history, key)
|
||||
if (recentKey() !== key) setRecentKey(key)
|
||||
},
|
||||
previous: () => moveHistory("previous"),
|
||||
next: () => moveHistory("next"),
|
||||
toggleHome(input: { home: boolean; current?: Tab }) {
|
||||
if (input.home) {
|
||||
const tab = store.find((tab) => tabKey(tab) === recentKey())
|
||||
|
||||
@@ -2,6 +2,17 @@ import { describe, expect, test } from "bun:test"
|
||||
import { DESKTOP_MENU } from "./desktop-menu"
|
||||
|
||||
describe("desktop menu", () => {
|
||||
test("navigates between tabs", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && (item.labelKey === "desktop.menu.previousTab" || item.labelKey === "desktop.menu.nextTab"),
|
||||
)
|
||||
|
||||
expect(items).toEqual([
|
||||
{ type: "item", labelKey: "desktop.menu.previousTab", command: "tab.prev", accelerator: { macos: "Option+Left" } },
|
||||
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Option+Right" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("exports logs through the desktop command registry", () => {
|
||||
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
|
||||
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
|
||||
|
||||
@@ -237,6 +237,20 @@ export const DESKTOP_MENU: DesktopMenu[] = [
|
||||
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } },
|
||||
{ type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.previousTab",
|
||||
command: "tab.prev",
|
||||
accelerator: { macos: "Option+Left" },
|
||||
},
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.nextTab",
|
||||
command: "tab.next",
|
||||
accelerator: { macos: "Option+Right" },
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ type: "separator" },
|
||||
{
|
||||
type: "item",
|
||||
labelKey: "desktop.menu.previousSession",
|
||||
|
||||
@@ -21,6 +21,12 @@ export function useProviders(directory: Accessor<string | undefined>) {
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const dir = () => (directory ? directory() : decode64(params.dir))
|
||||
createEffect(() => {
|
||||
const value = dir()
|
||||
if (value) {
|
||||
serverSync().enableProviders(value)
|
||||
}
|
||||
})
|
||||
const providers = () => {
|
||||
const value = dir()
|
||||
const projectStore = value ? serverSync().child(value)[0] : undefined
|
||||
|
||||
@@ -66,6 +66,8 @@ export const DESKTOP_NATIVE_ENGLISH = {
|
||||
"desktop.menu.toggleFullScreen": "Toggle Full Screen",
|
||||
"desktop.menu.back": "Back",
|
||||
"desktop.menu.forward": "Forward",
|
||||
"desktop.menu.previousTab": "Previous Tab",
|
||||
"desktop.menu.nextTab": "Next Tab",
|
||||
"desktop.menu.previousSession": "Previous Session",
|
||||
"desktop.menu.nextSession": "Next Session",
|
||||
"desktop.menu.previousProject": "Previous Project",
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, onCleanup, type JSX, startTransition } from "solid-js"
|
||||
import { produce } from "solid-js/store"
|
||||
import { useCommand } from "@/context/command"
|
||||
import {
|
||||
loadHomeSessionIndex,
|
||||
retainHomeSessions,
|
||||
loadProjectedHomeSessionIndex,
|
||||
type HomeSessionEvents,
|
||||
} from "@/context/global-sync/home-session-index"
|
||||
import { takeRecentSessions } from "@/context/global-sync/session-trim"
|
||||
import { decodeHomeSessionPage } from "@/context/session-message-decoder"
|
||||
import type { LocalProject } from "@/context/layout"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { ServerConnection } from "@/context/server"
|
||||
@@ -24,6 +24,7 @@ import { archiveHomeSession } from "../home-session-archive"
|
||||
import type { HomeController } from "./home-controller"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
const HOME_SESSION_RENDER_BATCH = 4
|
||||
export type HomeSessionRecord = {
|
||||
session: Session
|
||||
project: LocalProject
|
||||
@@ -66,8 +67,17 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
if (!ctx) return { sessions: [], eventSequence: 0 }
|
||||
const cache = homeSessions()
|
||||
const eventSequence = cache.eventSequence()
|
||||
const index = await loadHomeSessionIndex(
|
||||
(input, options) => ctx.sdk.client.v2.session.list(input, options),
|
||||
const index = await loadProjectedHomeSessionIndex(
|
||||
async (input, options) => {
|
||||
const response = await ctx.sdk.client.v2.session.list(input, { ...options, parseAs: "arrayBuffer" })
|
||||
if (!(response.data instanceof ArrayBuffer)) throw new Error("Home session response is not an ArrayBuffer")
|
||||
return {
|
||||
data: await decodeHomeSessionPage(response.data, {
|
||||
directories: projectDirectories(),
|
||||
limit: HOME_SESSION_LIMIT,
|
||||
}),
|
||||
}
|
||||
},
|
||||
eventSequence,
|
||||
signal,
|
||||
)
|
||||
@@ -79,13 +89,16 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
refetchOnMount: true,
|
||||
refetchOnReconnect: true,
|
||||
}))
|
||||
const indexedSessions = createMemo(() =>
|
||||
retainHomeSessions(
|
||||
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
|
||||
const indexedSessions = createMemo(() => {
|
||||
const directories = new Set(projectDirectories().map(pathKey))
|
||||
return takeRecentSessions(
|
||||
homeSessions()
|
||||
.sessions(sessionLoad.data, sessionEventLoad.data)
|
||||
.filter((session) => directories.has(pathKey(session.directory))),
|
||||
HOME_SESSION_LIMIT,
|
||||
Date.now(),
|
||||
),
|
||||
)
|
||||
Number.NEGATIVE_INFINITY,
|
||||
)
|
||||
})
|
||||
const allRecords = createMemo(() =>
|
||||
buildHomeSessionRecords({
|
||||
sessions: indexedSessions,
|
||||
@@ -94,43 +107,21 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
projectByID,
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
const prefetched = new Set<string>()
|
||||
|
||||
const [visible, setVisible] = createSignal(HOME_SESSION_RENDER_BATCH)
|
||||
let revealFrame: number | undefined
|
||||
createEffect(() => {
|
||||
const ctx = home.server.focusedContext()
|
||||
const conn = home.server.focused()
|
||||
if (!ctx || !conn) return
|
||||
records()
|
||||
.slice(0, 2)
|
||||
.forEach((record) => {
|
||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync.session
|
||||
.sync(record.session.id)
|
||||
.then(() =>
|
||||
Promise.all(
|
||||
(ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) =>
|
||||
(ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => {
|
||||
if (part.type !== "text" || !part.text) return []
|
||||
return preloadMarkdown(part.text, part.id)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
const count = Math.min(allRecords().length, HOME_SESSION_LIMIT)
|
||||
if (visible() >= count || revealFrame !== undefined) return
|
||||
revealFrame = requestAnimationFrame(() => {
|
||||
revealFrame = undefined
|
||||
setVisible((current) => Math.min(current + HOME_SESSION_RENDER_BATCH, count))
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (revealFrame !== undefined) cancelAnimationFrame(revealFrame)
|
||||
})
|
||||
const records = createMemo(() => allRecords().slice(0, visible()))
|
||||
const groups = createMemo(() => groupSessions(records(), language))
|
||||
command.register("home.palette", () => [
|
||||
{
|
||||
id: "command.palette",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { createEffect, Suspense, type ParentProps } from "solid-js"
|
||||
import { createEffect, lazy, Suspense, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { DebugBar } from "@/components/debug-bar"
|
||||
import { TabsInfoPopup } from "@/components/help-button"
|
||||
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
|
||||
import type { TitlebarUpdate } from "@/components/titlebar"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { setV2Toast, ToastRegion } from "@/utils/toast"
|
||||
|
||||
const Titlebar = lazy(() => import("@/components/titlebar").then((module) => ({ default: module.Titlebar })))
|
||||
const TabsInfoPopup = lazy(() =>
|
||||
import("@/components/help-button").then((module) => ({ default: module.TabsInfoPopup })),
|
||||
)
|
||||
|
||||
export default function NewLayout(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ debugTools: true })
|
||||
@@ -30,19 +34,23 @@ export default function NewLayout(props: ParentProps) {
|
||||
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
|
||||
}}
|
||||
>
|
||||
<Titlebar
|
||||
update={update}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Suspense fallback={<div class="h-10 shrink-0" />}>
|
||||
<Titlebar
|
||||
update={update}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Suspense>
|
||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</main>
|
||||
{import.meta.env.DEV && state.debugTools && <DebugBar inline />}
|
||||
<TabsInfoPopup />
|
||||
<Suspense>
|
||||
<TabsInfoPopup />
|
||||
</Suspense>
|
||||
<ToastRegion v2 />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -634,7 +634,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
running: number
|
||||
}
|
||||
|
||||
const prefetchChunk = 200
|
||||
const prefetchChunk = 50
|
||||
const prefetchConcurrency = 2
|
||||
const prefetchPendingLimit = 10
|
||||
const span = 4
|
||||
@@ -777,18 +777,11 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (params.id) return
|
||||
const sessions = currentSessions()
|
||||
if (sessions.length === 0) return
|
||||
|
||||
const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
|
||||
if (index === -1) return
|
||||
|
||||
if (!params.id) {
|
||||
const first = sessions[index]
|
||||
if (first) prefetchSession(first, "high")
|
||||
}
|
||||
|
||||
warm(sessions, index)
|
||||
const first = sessions[0]
|
||||
if (first) prefetchSession(first, "high")
|
||||
})
|
||||
|
||||
function navigateSessionByOffset(offset: number) {
|
||||
|
||||
@@ -143,7 +143,7 @@ function ProviderTip() {
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
delay="intent"
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -690,8 +690,12 @@ export default function Page() {
|
||||
queryFn: mode
|
||||
? () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
|
||||
.then((result) => result.data)
|
||||
.api.vcs.diff({
|
||||
location: { directory: sdk().directory },
|
||||
mode: mode === "git" ? "working" : mode,
|
||||
context: 0,
|
||||
})
|
||||
.then((result) => result.data.map((diff) => ({ ...diff, patch: "" })))
|
||||
.catch((error) => {
|
||||
console.debug("[session-review] failed to load vcs diff", { mode, error })
|
||||
return []
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createApiForServer, createSdkForServer } from "./server"
|
||||
import { createCompatibleApi } from "./server-compat"
|
||||
import { decodeVcsDiffData } from "./vcs-diff-data"
|
||||
import { decodeLegacySessionList } from "@/context/session-message-decode"
|
||||
|
||||
function setup(
|
||||
protocol: "v1" | "v2" | Promise<"v1" | "v2">,
|
||||
@@ -48,6 +50,8 @@ function setup(
|
||||
current: createApiForServer({ server, fetch: fetcher }),
|
||||
legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }),
|
||||
directory: "/repo",
|
||||
decodeVcsDiff: async (buffer) => decodeVcsDiffData(buffer),
|
||||
decodeSessionList: async (buffer) => decodeLegacySessionList(buffer),
|
||||
})
|
||||
return { api, requests }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { ServerApi } from "./server"
|
||||
import type { ServerProtocol } from "./server-protocol"
|
||||
import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type { AgentPartInput, FilePartInput, OpencodeClient, TextPartInput } from "@opencode-ai/sdk/v2/client"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
Project,
|
||||
ProjectCurrent,
|
||||
SessionApi,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
SessionShellInput,
|
||||
SessionShellOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { legacySessionInfo } from "@/context/session-message-decode"
|
||||
|
||||
type LegacyClient = OpencodeClient
|
||||
type LegacyFor = (directory?: string) => LegacyClient
|
||||
@@ -51,6 +53,8 @@ type CompatibleInput = {
|
||||
current: ServerApi
|
||||
legacy: LegacyFor
|
||||
directory?: string
|
||||
decodeVcsDiff: (buffer: ArrayBuffer) => Promise<FileDiffInfo[]>
|
||||
decodeSessionList: (buffer: ArrayBuffer) => Promise<SessionInfo[]>
|
||||
}
|
||||
|
||||
function mime(uri: string) {
|
||||
@@ -58,31 +62,6 @@ function mime(uri: string) {
|
||||
return match?.[1] ?? "application/octet-stream"
|
||||
}
|
||||
|
||||
function sessionInfo(session: Session): SessionInfo {
|
||||
return {
|
||||
id: session.id,
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID,
|
||||
agent: session.agent,
|
||||
model: session.model && {
|
||||
id: session.model.id,
|
||||
providerID: session.model.providerID,
|
||||
variant: session.model.variant,
|
||||
},
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: session.time,
|
||||
title: session.title,
|
||||
location: { directory: session.directory, workspaceID: session.workspaceID },
|
||||
subpath: session.path,
|
||||
revert: session.revert && {
|
||||
messageID: session.revert.messageID,
|
||||
partID: session.revert.partID,
|
||||
snapshot: session.revert.snapshot,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCompatibleApi(input: CompatibleInput): CompatibleApi {
|
||||
const v1 = createV1Api(input)
|
||||
return lazyApi(
|
||||
@@ -148,29 +127,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
search: value.search,
|
||||
limit: value.limit,
|
||||
},
|
||||
options,
|
||||
{ ...options, parseAs: "arrayBuffer" },
|
||||
)
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer")
|
||||
return { data: await input.decodeSessionList(result.data), cursor: {} }
|
||||
}
|
||||
const result = await legacy({ directory: value?.directory }).session.list({
|
||||
directory: value?.directory,
|
||||
roots: value?.parentID === null ? true : undefined,
|
||||
search: value?.search,
|
||||
limit: value?.limit,
|
||||
})
|
||||
return { data: (result.data ?? []).map(sessionInfo), cursor: {} }
|
||||
const result = await legacy({ directory: value?.directory }).session.list(
|
||||
{
|
||||
directory: value?.directory,
|
||||
roots: value?.parentID === null ? true : undefined,
|
||||
search: value?.search,
|
||||
limit: value?.limit,
|
||||
},
|
||||
{ parseAs: "arrayBuffer" },
|
||||
)
|
||||
if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer")
|
||||
return { data: await input.decodeSessionList(result.data), cursor: {} }
|
||||
},
|
||||
async create(value?: Parameters<ServerApi["session"]["create"]>[0]) {
|
||||
const result = await legacy(value?.location ?? undefined).session.create({
|
||||
directory: directory(value?.location ?? undefined),
|
||||
})
|
||||
if (!result.data) throw new Error("Failed to create session")
|
||||
return sessionInfo(result.data)
|
||||
return legacySessionInfo(result.data)
|
||||
},
|
||||
async get(value: Parameters<ServerApi["session"]["get"]>[0]) {
|
||||
const result = await legacy().session.get(value)
|
||||
if (!result.data) throw new Error(`Session not found: ${value.sessionID}`)
|
||||
return sessionInfo(result.data)
|
||||
return legacySessionInfo(result.data)
|
||||
},
|
||||
async active() {
|
||||
const result = await legacy().session.status()
|
||||
@@ -192,7 +176,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
async fork(value: Parameters<ServerApi["session"]["fork"]>[0]) {
|
||||
const result = await legacy().session.fork(value)
|
||||
if (!result.data) throw new Error("Failed to fork session")
|
||||
return sessionInfo(result.data)
|
||||
return legacySessionInfo(result.data)
|
||||
},
|
||||
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
|
||||
await legacy().session.abort(value)
|
||||
@@ -341,20 +325,15 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
return located(result.data ?? [], value?.location)
|
||||
},
|
||||
async diff(value: Parameters<ServerApi["vcs"]["diff"]>[0]) {
|
||||
const result = await legacy(value.location).vcs.diff({
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
})
|
||||
return located(
|
||||
(result.data ?? []).map((file) => ({
|
||||
file: file.file,
|
||||
patch: file.patch ?? "",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status ?? "modified",
|
||||
})),
|
||||
value.location,
|
||||
const result = await legacy(value.location).vcs.diff(
|
||||
{
|
||||
mode: value.mode === "working" ? "git" : value.mode,
|
||||
context: value.context,
|
||||
},
|
||||
{ parseAs: "arrayBuffer" },
|
||||
)
|
||||
if (!(result.data instanceof ArrayBuffer)) throw new Error("VCS diff response is not an ArrayBuffer")
|
||||
return located(await input.decodeVcsDiff(result.data), value.location)
|
||||
},
|
||||
},
|
||||
file: {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export function decodeVcsDiffData(buffer: ArrayBuffer): FileDiffInfo[] {
|
||||
const text = new TextDecoder().decode(buffer)
|
||||
return (text ? JSON.parse(text) : []).map(
|
||||
(file: {
|
||||
file: string
|
||||
patch?: string
|
||||
additions: number
|
||||
deletions: number
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}) => ({
|
||||
file: file.file,
|
||||
patch: file.patch ?? "",
|
||||
additions: file.additions,
|
||||
deletions: file.deletions,
|
||||
status: file.status ?? "modified",
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import VcsDiffDecoderWorkerUrl from "./vcs-diff-decoder.worker.ts?worker&url"
|
||||
|
||||
type Response = { id: number; data?: FileDiffInfo[]; error?: string }
|
||||
|
||||
let worker: Worker | undefined
|
||||
let nextID = 0
|
||||
const pending = new Map<number, { resolve: (value: FileDiffInfo[]) => void; reject: (error: Error) => void }>()
|
||||
let lastInput = 0
|
||||
document.addEventListener(
|
||||
"beforeinput",
|
||||
() => {
|
||||
lastInput = performance.now()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
|
||||
export function decodeVcsDiff(buffer: ArrayBuffer) {
|
||||
const id = ++nextID
|
||||
return new Promise<FileDiffInfo[]>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
getWorker().postMessage({ id, buffer }, [buffer])
|
||||
})
|
||||
}
|
||||
|
||||
function getWorker() {
|
||||
if (worker) return worker
|
||||
worker = new Worker(VcsDiffDecoderWorkerUrl, { type: "module" })
|
||||
worker.onmessage = (event: MessageEvent<Response>) => {
|
||||
const request = pending.get(event.data.id)
|
||||
if (!request) return
|
||||
pending.delete(event.data.id)
|
||||
if (event.data.error) {
|
||||
request.reject(new Error(event.data.error))
|
||||
return
|
||||
}
|
||||
resolveWhenInputIdle(request.resolve, event.data.data ?? [])
|
||||
}
|
||||
worker.onerror = (event) => {
|
||||
const error = new Error(event.message)
|
||||
pending.forEach((request) => request.reject(error))
|
||||
pending.clear()
|
||||
worker?.terminate()
|
||||
worker = undefined
|
||||
}
|
||||
return worker
|
||||
}
|
||||
|
||||
function resolveWhenInputIdle(resolve: (value: FileDiffInfo[]) => void, value: FileDiffInfo[], initial = true) {
|
||||
const active = document.activeElement
|
||||
const editing =
|
||||
active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
(active instanceof HTMLElement && active.isContentEditable)
|
||||
const delay = Math.max(lastInput + 100 - performance.now(), initial && editing ? 100 : 0)
|
||||
if (delay <= 0) {
|
||||
resolve(value)
|
||||
return
|
||||
}
|
||||
setTimeout(() => resolveWhenInputIdle(resolve, value, false), delay)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { decodeVcsDiffData } from "./vcs-diff-data"
|
||||
|
||||
type Request = { id: number; buffer: ArrayBuffer }
|
||||
|
||||
self.onmessage = (event: MessageEvent<Request>) => {
|
||||
try {
|
||||
self.postMessage({ id: event.data.id, data: decodeVcsDiffData(event.data.buffer) })
|
||||
} catch (error) {
|
||||
self.postMessage({ id: event.data.id, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -142,8 +142,11 @@ const main = Effect.gen(function* () {
|
||||
app.setAppUserModelId(appId)
|
||||
app.setPath(
|
||||
"userData",
|
||||
onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId),
|
||||
process.env.OPENCODE_PROFILE_USER_DATA ??
|
||||
(onboardingTestRoot ? join(onboardingTestRoot, "desktop") : join(app.getPath("appData"), appId)),
|
||||
)
|
||||
if (process.env.OPENCODE_PROFILE_USER_DATA)
|
||||
app.setPath("sessionData", join(process.env.OPENCODE_PROFILE_USER_DATA, "session"))
|
||||
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
|
||||
initializeOldLayoutEligibility(app.getPath("userData"))
|
||||
logger = initLogging()
|
||||
@@ -191,9 +194,16 @@ const main = Effect.gen(function* () {
|
||||
ensureLoopbackNoProxy()
|
||||
useEnvProxy()
|
||||
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
|
||||
const features = app.commandLine.getSwitchValue("enable-features")
|
||||
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
|
||||
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
|
||||
const features = [
|
||||
jsCallStackFeature,
|
||||
process.env.OPENCODE_PROFILE_LOAF === "1" ? "AlwaysLogLOAFURL" : "",
|
||||
app.commandLine.getSwitchValue("enable-features"),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",")
|
||||
app.commandLine.appendSwitch("enable-features", features)
|
||||
if (!app.isPackaged)
|
||||
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_PROFILE_CDP_PORT ?? "9222")
|
||||
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
|
||||
@@ -1,62 +1,7 @@
|
||||
import * as i18n from "@solid-primitives/i18n"
|
||||
|
||||
import { dict as desktopEn } from "./en"
|
||||
import { dict as desktopZh } from "./zh"
|
||||
import { dict as desktopZht } from "./zht"
|
||||
import { dict as desktopKo } from "./ko"
|
||||
import { dict as desktopDe } from "./de"
|
||||
import { dict as desktopEs } from "./es"
|
||||
import { dict as desktopFr } from "./fr"
|
||||
import { dict as desktopDa } from "./da"
|
||||
import { dict as desktopJa } from "./ja"
|
||||
import { dict as desktopPl } from "./pl"
|
||||
import { dict as desktopRu } from "./ru"
|
||||
import { dict as desktopUk } from "./uk"
|
||||
import { dict as desktopAr } from "./ar"
|
||||
import { dict as desktopNo } from "./no"
|
||||
import { dict as desktopBr } from "./br"
|
||||
import { dict as desktopBs } from "./bs"
|
||||
import { dict as desktopTr } from "./tr"
|
||||
import { dict as desktopHi } from "./hi"
|
||||
import { dict as desktopNl } from "./nl"
|
||||
import { dict as desktopId } from "./id"
|
||||
import { dict as desktopVi } from "./vi"
|
||||
import { dict as desktopIt } from "./it"
|
||||
import { dict as desktopUr } from "./ur"
|
||||
import { dict as desktopPa } from "./pa"
|
||||
import { dict as desktopAz } from "./az"
|
||||
import { dict as desktopFi } from "./fi"
|
||||
import { dict as desktopSv } from "./sv"
|
||||
import { dict as desktopTh } from "./th"
|
||||
|
||||
import { dict as appEn } from "../../../../app/src/i18n/en"
|
||||
import { dict as appZh } from "../../../../app/src/i18n/zh"
|
||||
import { dict as appZht } from "../../../../app/src/i18n/zht"
|
||||
import { dict as appKo } from "../../../../app/src/i18n/ko"
|
||||
import { dict as appDe } from "../../../../app/src/i18n/de"
|
||||
import { dict as appEs } from "../../../../app/src/i18n/es"
|
||||
import { dict as appFr } from "../../../../app/src/i18n/fr"
|
||||
import { dict as appDa } from "../../../../app/src/i18n/da"
|
||||
import { dict as appJa } from "../../../../app/src/i18n/ja"
|
||||
import { dict as appPl } from "../../../../app/src/i18n/pl"
|
||||
import { dict as appRu } from "../../../../app/src/i18n/ru"
|
||||
import { dict as appUk } from "../../../../app/src/i18n/uk"
|
||||
import { dict as appAr } from "../../../../app/src/i18n/ar"
|
||||
import { dict as appNo } from "../../../../app/src/i18n/no"
|
||||
import { dict as appBr } from "../../../../app/src/i18n/br"
|
||||
import { dict as appBs } from "../../../../app/src/i18n/bs"
|
||||
import { dict as appTr } from "../../../../app/src/i18n/tr"
|
||||
import { dict as appHi } from "../../../../app/src/i18n/hi"
|
||||
import { dict as appNl } from "../../../../app/src/i18n/nl"
|
||||
import { dict as appId } from "../../../../app/src/i18n/id"
|
||||
import { dict as appVi } from "../../../../app/src/i18n/vi"
|
||||
import { dict as appIt } from "../../../../app/src/i18n/it"
|
||||
import { dict as appUr } from "../../../../app/src/i18n/ur"
|
||||
import { dict as appPa } from "../../../../app/src/i18n/pa"
|
||||
import { dict as appAz } from "../../../../app/src/i18n/az"
|
||||
import { dict as appFi } from "../../../../app/src/i18n/fi"
|
||||
import { dict as appSv } from "../../../../app/src/i18n/sv"
|
||||
import { dict as appTh } from "../../../../app/src/i18n/th"
|
||||
|
||||
export type Locale =
|
||||
| "en"
|
||||
@@ -217,35 +162,40 @@ function pickLocale(value: unknown): Locale | null {
|
||||
|
||||
const base = i18n.flatten({ ...appEn, ...desktopEn })
|
||||
|
||||
function build(locale: Locale): Dictionary {
|
||||
const loaders = {
|
||||
zh: () => Promise.all([import("../../../../app/src/i18n/zh"), import("./zh")]),
|
||||
zht: () => Promise.all([import("../../../../app/src/i18n/zht"), import("./zht")]),
|
||||
ko: () => Promise.all([import("../../../../app/src/i18n/ko"), import("./ko")]),
|
||||
de: () => Promise.all([import("../../../../app/src/i18n/de"), import("./de")]),
|
||||
es: () => Promise.all([import("../../../../app/src/i18n/es"), import("./es")]),
|
||||
fr: () => Promise.all([import("../../../../app/src/i18n/fr"), import("./fr")]),
|
||||
da: () => Promise.all([import("../../../../app/src/i18n/da"), import("./da")]),
|
||||
ja: () => Promise.all([import("../../../../app/src/i18n/ja"), import("./ja")]),
|
||||
pl: () => Promise.all([import("../../../../app/src/i18n/pl"), import("./pl")]),
|
||||
ru: () => Promise.all([import("../../../../app/src/i18n/ru"), import("./ru")]),
|
||||
uk: () => Promise.all([import("../../../../app/src/i18n/uk"), import("./uk")]),
|
||||
ar: () => Promise.all([import("../../../../app/src/i18n/ar"), import("./ar")]),
|
||||
no: () => Promise.all([import("../../../../app/src/i18n/no"), import("./no")]),
|
||||
br: () => Promise.all([import("../../../../app/src/i18n/br"), import("./br")]),
|
||||
bs: () => Promise.all([import("../../../../app/src/i18n/bs"), import("./bs")]),
|
||||
tr: () => Promise.all([import("../../../../app/src/i18n/tr"), import("./tr")]),
|
||||
hi: () => Promise.all([import("../../../../app/src/i18n/hi"), import("./hi")]),
|
||||
nl: () => Promise.all([import("../../../../app/src/i18n/nl"), import("./nl")]),
|
||||
id: () => Promise.all([import("../../../../app/src/i18n/id"), import("./id")]),
|
||||
vi: () => Promise.all([import("../../../../app/src/i18n/vi"), import("./vi")]),
|
||||
it: () => Promise.all([import("../../../../app/src/i18n/it"), import("./it")]),
|
||||
ur: () => Promise.all([import("../../../../app/src/i18n/ur"), import("./ur")]),
|
||||
pa: () => Promise.all([import("../../../../app/src/i18n/pa"), import("./pa")]),
|
||||
az: () => Promise.all([import("../../../../app/src/i18n/az"), import("./az")]),
|
||||
fi: () => Promise.all([import("../../../../app/src/i18n/fi"), import("./fi")]),
|
||||
sv: () => Promise.all([import("../../../../app/src/i18n/sv"), import("./sv")]),
|
||||
th: () => Promise.all([import("../../../../app/src/i18n/th"), import("./th")]),
|
||||
}
|
||||
|
||||
async function build(locale: Locale): Promise<Dictionary> {
|
||||
if (locale === "en") return base
|
||||
if (locale === "zh") return { ...base, ...i18n.flatten(appZh), ...i18n.flatten(desktopZh) }
|
||||
if (locale === "zht") return { ...base, ...i18n.flatten(appZht), ...i18n.flatten(desktopZht) }
|
||||
if (locale === "de") return { ...base, ...i18n.flatten(appDe), ...i18n.flatten(desktopDe) }
|
||||
if (locale === "es") return { ...base, ...i18n.flatten(appEs), ...i18n.flatten(desktopEs) }
|
||||
if (locale === "fr") return { ...base, ...i18n.flatten(appFr), ...i18n.flatten(desktopFr) }
|
||||
if (locale === "da") return { ...base, ...i18n.flatten(appDa), ...i18n.flatten(desktopDa) }
|
||||
if (locale === "ja") return { ...base, ...i18n.flatten(appJa), ...i18n.flatten(desktopJa) }
|
||||
if (locale === "pl") return { ...base, ...i18n.flatten(appPl), ...i18n.flatten(desktopPl) }
|
||||
if (locale === "ru") return { ...base, ...i18n.flatten(appRu), ...i18n.flatten(desktopRu) }
|
||||
if (locale === "uk") return { ...base, ...i18n.flatten(appUk), ...i18n.flatten(desktopUk) }
|
||||
if (locale === "ar") return { ...base, ...i18n.flatten(appAr), ...i18n.flatten(desktopAr) }
|
||||
if (locale === "no") return { ...base, ...i18n.flatten(appNo), ...i18n.flatten(desktopNo) }
|
||||
if (locale === "br") return { ...base, ...i18n.flatten(appBr), ...i18n.flatten(desktopBr) }
|
||||
if (locale === "bs") return { ...base, ...i18n.flatten(appBs), ...i18n.flatten(desktopBs) }
|
||||
if (locale === "tr") return { ...base, ...i18n.flatten(appTr), ...i18n.flatten(desktopTr) }
|
||||
if (locale === "hi") return { ...base, ...i18n.flatten(appHi), ...i18n.flatten(desktopHi) }
|
||||
if (locale === "nl") return { ...base, ...i18n.flatten(appNl), ...i18n.flatten(desktopNl) }
|
||||
if (locale === "id") return { ...base, ...i18n.flatten(appId), ...i18n.flatten(desktopId) }
|
||||
if (locale === "vi") return { ...base, ...i18n.flatten(appVi), ...i18n.flatten(desktopVi) }
|
||||
if (locale === "it") return { ...base, ...i18n.flatten(appIt), ...i18n.flatten(desktopIt) }
|
||||
if (locale === "ur") return { ...base, ...i18n.flatten(appUr), ...i18n.flatten(desktopUr) }
|
||||
if (locale === "pa") return { ...base, ...i18n.flatten(appPa), ...i18n.flatten(desktopPa) }
|
||||
if (locale === "az") return { ...base, ...i18n.flatten(appAz), ...i18n.flatten(desktopAz) }
|
||||
if (locale === "fi") return { ...base, ...i18n.flatten(appFi), ...i18n.flatten(desktopFi) }
|
||||
if (locale === "sv") return { ...base, ...i18n.flatten(appSv), ...i18n.flatten(desktopSv) }
|
||||
if (locale === "th") return { ...base, ...i18n.flatten(appTh), ...i18n.flatten(desktopTh) }
|
||||
return { ...base, ...i18n.flatten(appKo), ...i18n.flatten(desktopKo) }
|
||||
const dictionaries = await loaders[locale]()
|
||||
return { ...base, ...i18n.flatten(dictionaries[0].dict), ...i18n.flatten(dictionaries[1].dict) }
|
||||
}
|
||||
|
||||
const state = {
|
||||
@@ -254,8 +204,6 @@ const state = {
|
||||
init: undefined as Promise<Locale> | undefined,
|
||||
}
|
||||
|
||||
state.dict = build(state.locale)
|
||||
|
||||
const translate = i18n.translator(() => state.dict, i18n.resolveTemplate)
|
||||
|
||||
export function t(key: keyof Dictionary, params?: Record<string, string | number>) {
|
||||
@@ -272,7 +220,7 @@ export function initI18n(): Promise<Locale> {
|
||||
const next = pickLocale(value) ?? state.locale
|
||||
|
||||
state.locale = next
|
||||
state.dict = build(next)
|
||||
state.dict = await build(next)
|
||||
return next
|
||||
})().catch(() => state.locale)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidj
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { t } from "./i18n"
|
||||
import { initI18n, t } from "./i18n"
|
||||
import { initializationData } from "./initialization"
|
||||
import { DesktopFirstLaunchOnboarding } from "./onboarding"
|
||||
import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom"
|
||||
@@ -60,6 +60,8 @@ if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
})
|
||||
}
|
||||
|
||||
void initI18n()
|
||||
|
||||
const [updaterState, setUpdaterState] = createSignal<UpdaterState>({ status: "disabled" })
|
||||
void window.api.updater.subscribe(setUpdaterState)
|
||||
|
||||
|
||||
@@ -158,6 +158,12 @@ describe("markdown stream", () => {
|
||||
expect(final.blocks[2]).toEqual({ raw: "- final item", src: "- final item", mode: "full" })
|
||||
})
|
||||
|
||||
test("splits completed markdown into bounded top-level blocks", () => {
|
||||
const result = project(undefined, "# Plan\n\nFirst paragraph.\n\nSecond paragraph.", false)
|
||||
|
||||
expect(result.blocks.map((block) => block.raw)).toEqual(["# Plan", "First paragraph.", "Second paragraph."])
|
||||
})
|
||||
|
||||
test("catches up paced text before finalizing", () => {
|
||||
const live = project(undefined, "# Plan\n\nFinished paragraph.\n\n- final", true)
|
||||
const final = project(live, `${live.text} item`, false)
|
||||
|
||||
@@ -51,7 +51,7 @@ function heal(text: string) {
|
||||
}
|
||||
|
||||
export function stream(text: string, live: boolean): Block[] {
|
||||
if (!live) return completedProjection(text).blocks
|
||||
if (!live) return completedBlocks(text)
|
||||
if (refs(text)) return [{ raw: text, src: heal(text), mode: "live" }] satisfies Block[]
|
||||
const tokens = marked.lexer(text)
|
||||
const tail = tokens.findLastIndex((token) => token.type !== "space")
|
||||
@@ -85,6 +85,17 @@ export function stream(text: string, live: boolean): Block[] {
|
||||
return [...result, { raw, src: openCode(code.raw), mode: "code", language: language(code.lang) }]
|
||||
}
|
||||
|
||||
function completedBlocks(text: string) {
|
||||
if (refs(text)) return completedProjection(text).blocks
|
||||
const tokens = marked.lexer(text)
|
||||
return tokens.flatMap((token): Block[] => {
|
||||
if (token.type === "space") return []
|
||||
if (token.type !== "code") return [{ raw: token.raw, src: token.raw, mode: "full" }]
|
||||
const code = token as Tokens.Code
|
||||
return [{ raw: code.raw, src: code.text, mode: "code", language: language(code.lang), complete: true }]
|
||||
})
|
||||
}
|
||||
|
||||
export function project(previous: Projection | undefined, text: string, live: boolean): Projection {
|
||||
if (!live) {
|
||||
const current =
|
||||
@@ -93,7 +104,7 @@ export function project(previous: Projection | undefined, text: string, live: bo
|
||||
: previous && text.startsWith(previous.text)
|
||||
? project(previous, text, true)
|
||||
: undefined
|
||||
if (!current) return completedProjection(text)
|
||||
if (!current) return { text, blocks: completedBlocks(text) }
|
||||
return {
|
||||
text,
|
||||
blocks: current.blocks.map((block) => {
|
||||
|
||||
@@ -491,6 +491,8 @@ export function Markdown(
|
||||
)
|
||||
|
||||
let copyCleanup: (() => void) | undefined
|
||||
let renderFrame: number | undefined
|
||||
let renderGeneration = 0
|
||||
|
||||
createEffect(() => {
|
||||
const container = root()
|
||||
@@ -499,6 +501,9 @@ export function Markdown(
|
||||
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
|
||||
if (!container) return
|
||||
if (isServer) return
|
||||
const generation = ++renderGeneration
|
||||
if (renderFrame !== undefined) cancelAnimationFrame(renderFrame)
|
||||
renderFrame = undefined
|
||||
if (content.length === 0) {
|
||||
disposeCopyButtons(container)
|
||||
container.innerHTML = ""
|
||||
@@ -515,24 +520,40 @@ export function Markdown(
|
||||
})
|
||||
activeCodeKeys.clear()
|
||||
nextCodeKeys.forEach((key) => activeCodeKeys.add(key))
|
||||
content.forEach((block, index) => updateBlock(container, index, block, labels))
|
||||
while (container.children.length > content.length) {
|
||||
const child = container.lastElementChild
|
||||
if (!child) break
|
||||
disposeCopyButtons(child)
|
||||
child.remove()
|
||||
let index = 0
|
||||
const update = () => {
|
||||
renderFrame = undefined
|
||||
if (generation !== renderGeneration) return
|
||||
const deadline = performance.now() + 8
|
||||
while (index < content.length && performance.now() < deadline) {
|
||||
updateBlock(container, index, content[index]!, labels)
|
||||
index += 1
|
||||
}
|
||||
if (index < content.length) {
|
||||
renderFrame = requestAnimationFrame(update)
|
||||
return
|
||||
}
|
||||
while (container.children.length > content.length) {
|
||||
const child = container.lastElementChild
|
||||
if (!child) break
|
||||
disposeCopyButtons(child)
|
||||
child.remove()
|
||||
}
|
||||
container
|
||||
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}))
|
||||
}
|
||||
container
|
||||
.querySelectorAll<HTMLElement>('[data-slot="markdown-copy-button"]')
|
||||
.forEach((button) => setCopyState(button, labels, button.dataset.copied === "true"))
|
||||
if (!copyCleanup)
|
||||
copyCleanup = setupCodeCopy(container, () => ({
|
||||
copy: i18n.t("ui.message.copy"),
|
||||
copied: i18n.t("ui.message.copied"),
|
||||
}))
|
||||
update()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
renderGeneration += 1
|
||||
if (renderFrame !== undefined) cancelAnimationFrame(renderFrame)
|
||||
if (copyCleanup) copyCleanup()
|
||||
disposeMarkdownProjection(owner)
|
||||
activeCodeKeys.forEach(disposeCode)
|
||||
|
||||
@@ -24,7 +24,7 @@ function createPool(lineDiffType: "none" | "word-alt") {
|
||||
{
|
||||
theme: "OpenCode",
|
||||
lineDiffType,
|
||||
preferredHighlighter: "shiki-wasm",
|
||||
preferredHighlighter: "shiki-js",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ export function CommentCardV2(props: {
|
||||
return (
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={props.title ?? props.comment}
|
||||
disabled={!props.tooltip || !truncated()}
|
||||
class={props.wide ? "w-full" : undefined}
|
||||
|
||||
@@ -52,9 +52,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
let localInput = false
|
||||
const updateCursor = () => {
|
||||
const updateCursor = (event: KeyboardEvent | PointerEvent) => {
|
||||
if (!editor || !window.getSelection()?.isCollapsed) return
|
||||
props.controller.onCursor(promptInputV2Cursor(editor))
|
||||
if (event instanceof KeyboardEvent && !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key))
|
||||
return
|
||||
props.controller.onCursor(parsePromptInputV2Editor(editor).cursor)
|
||||
}
|
||||
const mode = createMemo(() => state.mode)
|
||||
const buttons = createMemo(() => ({
|
||||
@@ -163,8 +165,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
|
||||
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
|
||||
onInput={(event) => {
|
||||
const cursor = promptInputV2Cursor(event.currentTarget)
|
||||
const prompt = parsePromptInputV2Editor(event.currentTarget)
|
||||
const { prompt, cursor } = parsePromptInputV2Editor(event.currentTarget)
|
||||
const images = props.controller.parts().filter((part) => part.type === "image")
|
||||
localInput = true
|
||||
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
|
||||
@@ -300,8 +301,13 @@ function renderPromptInputV2Editor(editor: HTMLDivElement, prompt: PromptInputV2
|
||||
|
||||
function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||
const parts: Exclude<PromptInputV2Prompt[number], PromptInputV2Attachment>[] = []
|
||||
const selection = window.getSelection()
|
||||
const anchorNode = selection && editor.contains(selection.anchorNode) ? selection.anchorNode : undefined
|
||||
const anchorOffset = anchorNode ? selection!.anchorOffset : 0
|
||||
let buffer = ""
|
||||
let position = 0
|
||||
let cursor: number | undefined
|
||||
const offset = () => position + buffer.length
|
||||
|
||||
const flush = () => {
|
||||
if (!buffer) return
|
||||
@@ -336,43 +342,42 @@ function parsePromptInputV2Editor(editor: HTMLDivElement) {
|
||||
}
|
||||
const visit = (node: Node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
if (node === anchorNode) cursor = offset() + Math.min(anchorOffset, node.textContent?.length ?? 0)
|
||||
buffer += node.textContent ?? ""
|
||||
return
|
||||
}
|
||||
if (!(node instanceof HTMLElement)) return
|
||||
if (node.dataset.mention) {
|
||||
if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? (node.textContent?.length ?? 0) : 0)
|
||||
mention(node)
|
||||
return
|
||||
}
|
||||
if (node.tagName === "BR") {
|
||||
if (node === anchorNode) cursor = offset() + (anchorOffset > 0 ? 1 : 0)
|
||||
buffer += "\n"
|
||||
return
|
||||
}
|
||||
Array.from(node.childNodes).forEach(visit)
|
||||
Array.from(node.childNodes).forEach((child, index) => {
|
||||
if (node === anchorNode && anchorOffset === index) cursor = offset()
|
||||
visit(child)
|
||||
})
|
||||
if (node === anchorNode && anchorOffset >= node.childNodes.length) cursor = offset()
|
||||
}
|
||||
|
||||
Array.from(editor.childNodes).forEach((node, index, nodes) => {
|
||||
if (editor === anchorNode && anchorOffset === index) cursor = offset()
|
||||
visit(node)
|
||||
if (node instanceof HTMLElement && ["DIV", "P"].includes(node.tagName) && index < nodes.length - 1) buffer += "\n"
|
||||
})
|
||||
if (editor === anchorNode && anchorOffset >= editor.childNodes.length) cursor = offset()
|
||||
flush()
|
||||
if (
|
||||
parts.every((part) => part.type === "text") &&
|
||||
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === "")
|
||||
) {
|
||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||
}
|
||||
if (parts.length > 0) return parts
|
||||
return [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||
}
|
||||
|
||||
function promptInputV2Cursor(editor: HTMLDivElement) {
|
||||
const selection = window.getSelection()
|
||||
if (!selection?.rangeCount || !editor.contains(selection.anchorNode)) return editor.textContent?.length ?? 0
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
range.selectNodeContents(editor)
|
||||
range.setEnd(selection.anchorNode!, selection.anchorOffset)
|
||||
return range.toString().length
|
||||
const result =
|
||||
parts.length === 0 ||
|
||||
(parts.every((part) => part.type === "text") &&
|
||||
parts.every((part) => part.content.replace(/[\n\u200B]/g, "") === ""))
|
||||
? [{ type: "text" as const, content: "", start: 0, end: 0 }]
|
||||
: parts
|
||||
return { prompt: result, cursor: cursor ?? offset() }
|
||||
}
|
||||
|
||||
export function PromptInputV2Attachments(props: {
|
||||
@@ -396,12 +401,7 @@ export function PromptInputV2Attachments(props: {
|
||||
<For each={props.comments ?? []}>
|
||||
{(comment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2
|
||||
value={comment.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<CommentCardV2
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
|
||||
@@ -220,7 +220,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
</Show>
|
||||
<div class="flex items-center">
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!prev()}
|
||||
value={
|
||||
<>
|
||||
@@ -240,7 +239,6 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!next()}
|
||||
value={
|
||||
<>
|
||||
@@ -274,12 +272,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
||||
>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<Icon name="expand" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<Icon name="collapse" />
|
||||
</SegmentedControlItemV2>
|
||||
@@ -295,12 +293,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
||||
>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<Icon name="unified" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<Icon name="split" />
|
||||
</SegmentedControlItemV2>
|
||||
|
||||
@@ -104,7 +104,6 @@ const appGlobalBindingCommands = [
|
||||
] as const
|
||||
|
||||
const appBindingCommands = [
|
||||
"command.palette.show",
|
||||
"model.list",
|
||||
"model.cycle_recent",
|
||||
"model.cycle_recent_reverse",
|
||||
@@ -963,6 +962,11 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
||||
commands: appCommands(),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
enabled: () => dialog.stack.length === 0,
|
||||
bindings: tuiConfig.keybinds.get(COMMAND_PALETTE_COMMAND),
|
||||
}))
|
||||
|
||||
useBindings(() => ({
|
||||
mode: OPENCODE_BASE_MODE,
|
||||
bindings: tuiConfig.keybinds.gather("app", appBindingCommands),
|
||||
|
||||
@@ -5,7 +5,13 @@ import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { TuiKeybind } from "../src/config/keybind"
|
||||
import { getOpencodeModeStack, OPENCODE_BASE_MODE, OpencodeKeymapProvider, registerOpencodeKeymap } from "../src/keymap"
|
||||
import {
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
getOpencodeModeStack,
|
||||
OPENCODE_BASE_MODE,
|
||||
OpencodeKeymapProvider,
|
||||
registerOpencodeKeymap,
|
||||
} from "../src/keymap"
|
||||
|
||||
function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
|
||||
const keybinds = TuiKeybind.parse(input)
|
||||
@@ -73,12 +79,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
|
||||
const offGlobal = keymap.registerLayer({
|
||||
commands: [
|
||||
{ name: COMMAND_PALETTE_COMMAND, run() {} },
|
||||
{ name: "session.list", run() {} },
|
||||
{ name: "session.new", run() {} },
|
||||
{ name: "session.page.up", run() {} },
|
||||
{ name: "session.first", run() {} },
|
||||
],
|
||||
bindings: config.keybinds.gather("test.global", [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
@@ -95,7 +103,14 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
Array.from(
|
||||
keymap.getCommandBindings({
|
||||
visibility: "active",
|
||||
commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
|
||||
commands: [
|
||||
COMMAND_PALETTE_COMMAND,
|
||||
"session.list",
|
||||
"session.new",
|
||||
"session.page.up",
|
||||
"session.first",
|
||||
"model.list",
|
||||
],
|
||||
}),
|
||||
([command, bindings]) => [command, bindings.length],
|
||||
),
|
||||
@@ -125,9 +140,24 @@ test("mode-less bindings stay active when opencode mode changes", async () => {
|
||||
const app = await testRender(() => <Harness />)
|
||||
try {
|
||||
expect(counts).toEqual({
|
||||
base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
|
||||
question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
|
||||
base: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 1,
|
||||
},
|
||||
question: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
"session.first": 2,
|
||||
"model.list": 0,
|
||||
},
|
||||
autocomplete: {
|
||||
[COMMAND_PALETTE_COMMAND]: 1,
|
||||
"session.list": 1,
|
||||
"session.new": 1,
|
||||
"session.page.up": 2,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
const OPEN_DELAY = 1_000
|
||||
|
||||
// Kobalte warms every tooltip globally. Keep intent previews isolated so opening
|
||||
// a model picker never inherits warm state from an unrelated tooltip.
|
||||
let warm = false
|
||||
let reset: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
export function openTooltipIntent(open: () => void) {
|
||||
clearTimeout(reset)
|
||||
reset = undefined
|
||||
if (warm) {
|
||||
open()
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
warm = true
|
||||
open()
|
||||
}, OPEN_DELAY)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
export function closeTooltipIntent() {
|
||||
clearTimeout(reset)
|
||||
// Adjacent triggers enter before the next task; leaving the group does not.
|
||||
reset = setTimeout(() => {
|
||||
warm = false
|
||||
reset = undefined
|
||||
})
|
||||
}
|
||||
|
||||
export function resetTooltipIntent() {
|
||||
clearTimeout(reset)
|
||||
reset = undefined
|
||||
warm = false
|
||||
}
|
||||
@@ -2,19 +2,22 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
|
||||
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
|
||||
import type { ComponentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { closeTooltipIntent, openTooltipIntent, resetTooltipIntent } from "./tooltip-intent"
|
||||
import "./tooltip-v2.css"
|
||||
|
||||
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
|
||||
export interface TooltipV2Props extends Omit<ComponentProps<typeof KobalteTooltip>, "openDelay"> {
|
||||
value: JSX.Element
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
inactive?: boolean
|
||||
delay?: "standard" | "intent"
|
||||
forceOpen?: boolean
|
||||
}
|
||||
|
||||
export function TooltipV2(props: TooltipV2Props) {
|
||||
let ref: HTMLDivElement | undefined
|
||||
let cancelIntent: (() => void) | undefined
|
||||
const [state, setState] = createStore({
|
||||
open: false,
|
||||
block: false,
|
||||
@@ -26,19 +29,37 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
"contentClass",
|
||||
"contentStyle",
|
||||
"inactive",
|
||||
"delay",
|
||||
"forceOpen",
|
||||
"ignoreSafeArea",
|
||||
"value",
|
||||
])
|
||||
|
||||
const close = () => setState("open", false)
|
||||
|
||||
const inside = () => {
|
||||
const active = document.activeElement
|
||||
if (!ref || !active) return false
|
||||
return ref.contains(active)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
cancelIntent?.()
|
||||
cancelIntent = undefined
|
||||
if (local.delay === "intent") closeTooltipIntent()
|
||||
setState("open", false)
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
if (local.delay !== "intent" || inside()) {
|
||||
setState("open", true)
|
||||
return
|
||||
}
|
||||
if (cancelIntent) return
|
||||
cancelIntent = openTooltipIntent(() => {
|
||||
cancelIntent = undefined
|
||||
setState("open", true)
|
||||
})
|
||||
}
|
||||
|
||||
const drop = (expand = state.expand) => {
|
||||
if (expand) return
|
||||
if (ref?.matches(":hover")) return
|
||||
@@ -80,6 +101,11 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
onCleanup(() => obs.disconnect())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
cancelIntent?.()
|
||||
if (local.delay === "intent") resetTooltipIntent()
|
||||
})
|
||||
|
||||
let justClickedTrigger = false
|
||||
|
||||
return (
|
||||
@@ -88,8 +114,8 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
<Match when={true}>
|
||||
<KobalteTooltip
|
||||
gutter={4}
|
||||
openDelay={400}
|
||||
skipDelayDuration={300}
|
||||
openDelay={local.delay === "intent" ? 0 : 400}
|
||||
skipDelayDuration={local.delay === "intent" ? 0 : 300}
|
||||
{...others}
|
||||
closeDelay={0}
|
||||
ignoreSafeArea={local.ignoreSafeArea ?? true}
|
||||
@@ -101,7 +127,11 @@ export function TooltipV2(props: TooltipV2Props) {
|
||||
justClickedTrigger = false
|
||||
return
|
||||
}
|
||||
setState("open", open)
|
||||
if (open) {
|
||||
show()
|
||||
return
|
||||
}
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<KobalteTooltip.Trigger
|
||||
|
||||
Reference in New Issue
Block a user