Compare commits

..

3 Commits

Author SHA1 Message Date
Aiden Cline ec721b2296 fix(opencode): match complete retry messages 2026-08-05 11:53:07 -05:00
Aiden Cline 29bd71459d fix(opencode): harden retry message classification 2026-08-05 11:38:00 -05:00
Aiden Cline efccad2684 fix(opencode): expand transient retry matching 2026-08-05 11:17:35 -05:00
122 changed files with 939 additions and 7353 deletions
-26
View File
@@ -56,32 +56,6 @@ Benchmarks do not assert machine-dependent performance budgets. Streaming proces
Committed smoke and regression tests continue to own correctness coverage for pagination, tab paint, context resize, collapse state, and composer spacing.
## Desktop profiler
The desktop profiler launches the existing production build directly, creates isolated desktop state, chooses an available CDP port, and writes reports under the OS temporary directory by default.
```sh
bun run profile:desktop --help
```
Create a private partial snapshot from the default local database and run Home once:
```sh
bun run profile:desktop --partial-snapshot-out /tmp/opencode-perf.db \
--window-end 2026-08-04T06:14:26.878Z \
--scenarios home,calibration --skip-build
```
Repeat against the immutable partial snapshot:
```sh
bun run profile:desktop --mode partial-snapshot --db /tmp/opencode-perf.db \
--window-end 2026-08-04T06:14:26.878Z \
--scenarios home,calibration --runs 3 --skip-build
```
Partial snapshots contain private application data and must not be committed or shared. The profiler copies each partial snapshot to a per-run working database and remaps selected project paths to temporary workspaces, leaving the source snapshot unchanged. `PROFILE_SUMMARY` is the compact comparison output; `PROFILE_REPORT` points to the complete JSON report with the database hash, invocation parameters, raw runs, and attribution data.
## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically:
@@ -1,137 +0,0 @@
import { Database } from "bun:sqlite"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { progress } from "./progress"
import type { Options, Target } from "./types"
export async function createPartialSnapshot(source: string, destination: string, options: Options, targets: Target[]) {
await mkdir(path.dirname(destination), { recursive: true })
await rm(destination, { force: true })
const input = new Database(source, { readonly: true })
const schema = input
.query(
`SELECT type, name, sql FROM sqlite_schema
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END, name`,
)
.all() as { type: string; name: string; sql: string }[]
input.close()
const output = new Database(destination, { create: true })
output.run("PRAGMA foreign_keys = OFF")
schema.filter((item) => item.type === "table").forEach((item) => output.run(item.sql))
output.run("ATTACH DATABASE ? AS source", source)
const selected = [...new Set(targets.map((target) => target.id))]
const placeholders = selected.map(() => "?").join(",")
for (const table of schema.filter((item) => item.type === "table").map((item) => item.name)) {
progress("copying partial snapshot table", { table })
if (table === "event") continue
if (table === "message") {
output.run(
`INSERT INTO main.message SELECT * FROM source.message
WHERE (time_created >= ? AND time_created < ? AND session_id IN (
SELECT id FROM source.session WHERE parent_id IS NULL
)) OR session_id IN (${placeholders})`,
options.windowStart,
options.windowEnd,
...selected,
)
continue
}
if (table === "part") {
output.run("INSERT INTO main.part SELECT * FROM source.part WHERE message_id IN (SELECT id FROM main.message)")
continue
}
if (["session_context_epoch", "session_input", "session_message", "session_share", "todo"].includes(table)) {
output.run(
`INSERT INTO main."${table}" SELECT * FROM source."${table}" WHERE session_id IN (${placeholders})`,
...selected,
)
continue
}
output.run(`INSERT INTO main."${table}" SELECT * FROM source."${table}"`)
}
output.run("DETACH DATABASE source")
schema.filter((item) => item.type !== "table").forEach((item) => output.run(item.sql))
output.close()
}
export async function fingerprint(file: string) {
const input = Bun.file(file)
const hasher = new Bun.CryptoHasher("sha256")
for await (const chunk of input.stream()) hasher.update(chunk)
return { bytes: input.size, sha256: hasher.digest("hex") }
}
export function loadCorpus(options: Options) {
const database = new Database(options.database, { readonly: true })
database.run("PRAGMA query_only = ON")
const sessions = database
.query(
`SELECT id, project_id AS projectID, 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(options.windowStart, options.windowEnd) as { id: string; projectID: 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, options.windowStart, options.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))
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)]!,
})
const targets = [select("p50", 0.5), select("p95", 0.95), select("max", 1)] satisfies Target[]
const typingText = loadTypingText(database, partRows, messageRows, targets[2]!, options)
const projectIDs = [...new Set(ranked.map((session) => session.projectID))]
database.close()
return { targets, typingText, projectIDs }
}
function loadTypingText(
database: Database,
partRows: ReturnType<Database["query"]>,
messageRows: ReturnType<Database["query"]>,
target: Target,
options: Options,
) {
const messages = messageRows.all(target.id, options.windowStart, options.windowEnd) as { id: string; data: string }[]
const text = messages
.filter((message) => JSON.parse(message.data).role === "user")
.flatMap((message) =>
(partRows.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]
if (!text) throw new Error("No real user prompt found for composer profiling")
return text
}
@@ -1,116 +0,0 @@
import { Database } from "bun:sqlite"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import type { Options } from "./types"
export async function prepareDesktopState(
options: Options,
databasePath: string,
userData: string,
run: number,
projectIDs: string[],
) {
const database = new Database(databasePath)
const projects = database.query("SELECT id, worktree, sandboxes FROM project ORDER BY id").all() as {
id: string
worktree: string
sandboxes: string
}[]
const selected = new Set(projectIDs)
const profileProjects = projects.filter((project) => selected.has(project.id))
const worktrees =
options.mode === "partial-snapshot"
? await remapDirectories(database, profileProjects, path.join(options.output, "workspaces", String(run)))
: profileProjects.map((project) => project.worktree)
database.close()
await Bun.write(
path.join(userData, "opencode.settings"),
JSON.stringify({ firstLaunchOnboardingComplete: true, oldLayoutEligible: true, tauriMigrated: true }),
)
await Bun.write(
path.join(userData, "opencode.global.dat"),
JSON.stringify({
server: JSON.stringify({
list: [],
projects: { local: worktrees.map((worktree) => ({ worktree, expanded: true })) },
lastProject: worktrees[0] ? { local: worktrees[0] } : {},
recentlyClosed: {},
}),
}),
)
}
async function remapDirectories(
database: Database,
projects: { id: string; worktree: string; sandboxes: string }[],
root: string,
) {
await mkdir(root, { recursive: true })
const mappings = new Map<string, string>()
const worktrees = await Promise.all(
projects.map(async (project, index) => {
const worktree = path.join(root, `project-${String(index + 1).padStart(3, "0")}`)
await mkdir(worktree, { recursive: true })
mappings.set(project.worktree, worktree)
const sandboxes = JSON.parse(project.sandboxes) as string[]
const nextSandboxes = await Promise.all(
sandboxes.map(async (sandbox, sandboxIndex) => {
const next = path.join(worktree, `sandbox-${sandboxIndex + 1}`)
await mkdir(next, { recursive: true })
mappings.set(sandbox, next)
return next
}),
)
database.run("UPDATE project SET worktree = ?, sandboxes = ? WHERE id = ?", worktree, JSON.stringify(nextSandboxes), project.id)
return worktree
}),
)
const byProject = new Map(projects.map((project, index) => [project.id, worktrees[index]!]))
const sessions = database.query("SELECT id, project_id, directory FROM session").all() as {
id: string
project_id: string
directory: string
}[]
const directories = database.query("SELECT * FROM project_directory").all() as {
project_id: string
directory: string
type: string | null
strategy: string | null
time_created: number
}[]
const selected = new Set(projects.map((project) => project.id))
const nextDirectories = await Promise.all(
directories.filter((item) => selected.has(item.project_id)).map(async (item, index) => {
const directory =
mappings.get(item.directory) ?? path.join(byProject.get(item.project_id) ?? root, `directory-${index + 1}`)
await mkdir(directory, { recursive: true })
return { ...item, directory }
}),
)
database.transaction(() => {
sessions.filter((session) => selected.has(session.project_id)).forEach((session) =>
database.run(
"UPDATE session SET directory = ? WHERE id = ?",
mappings.get(session.directory) ?? byProject.get(session.project_id) ?? worktrees[0]!,
session.id,
),
)
database.run(
`DELETE FROM project_directory WHERE project_id IN (${projects.map(() => "?").join(",")})`,
...projects.map((project) => project.id),
)
nextDirectories.forEach((item) =>
database.run(
`INSERT INTO project_directory (project_id, directory, type, strategy, time_created)
VALUES (?, ?, ?, ?, ?)`,
item.project_id,
item.directory,
item.type,
item.strategy,
item.time_created,
),
)
})()
return worktrees
}
@@ -1,54 +0,0 @@
import { Database } from "bun:sqlite"
import { afterAll, expect, test } from "bun:test"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { createPartialSnapshot, fingerprint } from "./corpus"
import { parseOptions } from "./options"
const directory = path.join(import.meta.dir, `.tmp-${process.pid}`)
const source = path.join(directory, "source.db")
const partialSnapshot = path.join(directory, "partial-snapshot.db")
await mkdir(directory, { recursive: true })
const database = new Database(source, { create: true })
database.run("CREATE TABLE sample (value TEXT NOT NULL)")
database.run("INSERT INTO sample VALUES ('repeatable')")
database.close()
afterAll(() => rm(directory, { recursive: true, force: true }))
test("parses a portable fixed-window partial snapshot invocation", () => {
const options = parseOptions([
"--mode",
"partial-snapshot",
"--db",
source,
"--window-end",
"2026-08-04T06:14:26.878Z",
"--window-hours",
"24",
"--scenarios",
"home,calibration",
"--runs",
"3",
"--skip-build",
])!
expect(options.database).toBe(source)
expect(options.windowEnd).toBe(1_785_824_066_878)
expect(options.windowStart).toBe(1_785_737_666_878)
expect(options.scenarios).toEqual(["home", "calibration"])
expect(options.runs).toBe(3)
expect(options.build).toBe(false)
})
test("creates a consistent private partial database snapshot", async () => {
const options = parseOptions(["--db", source, "--window-end", "2026-08-04T06:14:26.878Z"])!
await createPartialSnapshot(source, partialSnapshot, options, [])
const copy = new Database(partialSnapshot, { readonly: true })
expect(copy.query("SELECT value FROM sample").get()).toEqual({ value: "repeatable" })
copy.close()
expect(await fingerprint(partialSnapshot)).toEqual({
bytes: expect.any(Number),
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
})
})
@@ -1,88 +0,0 @@
import { Global } from "@opencode-ai/core/global"
import { existsSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { scenarios, type Options, type Scenario } from "./types"
const help = `Desktop renderer profiler
Usage:
bun run profile:desktop [options]
Options:
--mode local|partial-snapshot
Local corpus or fixed partial snapshot (default: local)
--db <path> SQLite database (default: opencode data directory)
--partial-snapshot-out <path>
Copy the benchmark corpus to a private partial snapshot
--output <directory> Report directory (default: OS temp directory)
--window-end <ISO|epoch> End of corpus window (default: now; required for partial snapshot)
--window-hours <hours> Corpus window size (default: 24)
--scenarios <names> Comma list: ${scenarios.join(",")} (default: all)
--runs <count> Restart Electron and repeat (default: 1)
--skip-build Use the existing desktop production build
--diagnostics Capture Chrome traces
--cpu Capture sampled CPU summaries
--response-urls Attribute Response.text durations by URL
--help Show this message
Partial snapshots contain private application data. Do not commit or share them.
`
export function parseOptions(args: string[], now = Date.now()): Options | undefined {
if (args.includes("--help")) {
console.log(help)
return
}
const value = (name: string) => {
const index = args.indexOf(name)
if (index === -1) return
const result = args[index + 1]
if (!result || result.startsWith("--")) throw new Error(`${name} requires a value`)
return result
}
const mode = value("--mode") ?? "local"
if (mode !== "local" && mode !== "partial-snapshot") throw new Error(`Unsupported mode: ${mode}`)
const endValue = value("--window-end")
if (mode === "partial-snapshot" && !endValue)
throw new Error("--window-end is required in partial-snapshot mode")
const windowEnd = endValue ? parseTime(endValue) : now
const windowHours = number(value("--window-hours") ?? "24", "--window-hours")
const selected = (value("--scenarios")?.split(",") ?? [...scenarios]).map((item) => item.trim())
if (selected.some((item) => !scenarios.includes(item as Scenario)))
throw new Error(`--scenarios must contain only: ${scenarios.join(", ")}`)
const database = path.resolve(value("--db") ?? path.join(Global.Path.data, "opencode.db"))
if (!existsSync(database)) throw new Error(`Database does not exist: ${database}`)
return {
mode,
database,
output: path.resolve(
value("--output") ?? path.join(tmpdir(), "opencode-performance", new Date(windowEnd).toISOString().replace(/[:.]/g, "-")),
),
windowStart: windowEnd - windowHours * 60 * 60 * 1_000,
windowEnd,
scenarios: selected as Scenario[],
runs: number(value("--runs") ?? "1", "--runs"),
build: !args.includes("--skip-build"),
diagnostics: args.includes("--diagnostics"),
cpu: args.includes("--cpu"),
responseURLs: args.includes("--response-urls"),
partialSnapshotOut: value("--partial-snapshot-out")
? path.resolve(value("--partial-snapshot-out")!)
: undefined,
}
}
function parseTime(value: string) {
const result = /^\d+$/.test(value) ? Number(value) : Date.parse(value)
if (!Number.isFinite(result)) throw new Error(`Invalid --window-end: ${value}`)
return result
}
function number(value: string, option: string) {
const result = Number(value)
if (!Number.isFinite(result) || result <= 0) throw new Error(`${option} must be greater than zero`)
return result
}
@@ -1,192 +0,0 @@
import type { Page } from "@playwright/test"
import type { Options, ProbeResult } from "./types"
export async function installProbe(page: Page, options: Options) {
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) return
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
})
}
}, options.responseURLs)
}
export 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
})
}
export async function collectProbe(page: Page) {
return page.evaluate(
() => (window as Window & { __opencodeRendererProfile?: ProbeResult }).__opencodeRendererProfile!,
)
}
export 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: responsePath(item.url), durationMs: item.duration }))
.sort((a, b) => b.durationMs - a.durationMs),
}
}
export async function startCPUProfile(page: Page, enabled: boolean) {
if (!enabled) 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)
},
}
}
function responsePath(value: string) {
try {
return new URL(value).pathname
} catch {
return value
}
}
function sourceName(value: string) {
if (!value) return "(native)"
try {
return new URL(value).pathname.split("/").at(-1) || "(document)"
} catch {
return value.split(/[\\/]/).at(-1) || value
}
}
function sum(values: number[]) {
return values.reduce((total, value) => total + value, 0)
}
@@ -1,7 +0,0 @@
const started = performance.now()
export function progress(message: string, details?: Record<string, unknown>) {
const elapsed = ((performance.now() - started) / 1_000).toFixed(1)
const suffix = details ? ` ${JSON.stringify(details)}` : ""
console.error(`[desktop-profile +${elapsed}s] ${message}${suffix}`)
}
@@ -1,156 +0,0 @@
import { chromium, type Page } from "@playwright/test"
import { copyFile, mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { prepareDesktopState } from "./desktop-state"
import { progress } from "./progress"
import type { Options } from "./types"
export async function withDesktop<T>(
options: Options,
desktop: string,
run: number,
projectIDs: string[],
use: (page: Page) => Promise<T>,
) {
const port = availablePort()
const endpoint = `http://127.0.0.1:${port}`
const userData = path.join(options.output, `user-data-${run}`)
const database =
options.mode === "partial-snapshot" ? path.join(options.output, `working-database-${run}.db`) : options.database
await rm(userData, { recursive: true, force: true })
await mkdir(userData, { recursive: true })
if (database !== options.database) await copyFile(options.database, database)
await prepareDesktopState(options, database, userData, run, projectIDs)
const electron = path.join(
desktop,
"node_modules",
"electron",
"dist",
(await Bun.file(path.join(desktop, "node_modules", "electron", "path.txt")).text()).trim(),
)
progress("launching Electron", { run, port })
const child = Bun.spawn([electron, "."], {
cwd: desktop,
env: {
...process.env,
OPENCODE_DB: database,
OPENCODE_CHANNEL: "dev",
OPENCODE_PROFILE_LOAF: "1",
OPENCODE_PROFILE_CDP_PORT: String(port),
OPENCODE_PROFILE_USER_DATA: userData,
OPENCODE_PERFORMANCE_TRACE_DIR: options.diagnostics ? path.join(options.output, "traces", String(run)) : "",
OPENCODE_PERFORMANCE_RUN_ID: `desktop-${run}`,
},
stdout: "pipe",
stderr: "pipe",
})
const stdout = drain(child.stdout, "stdout")
const stderr = drain(child.stderr, "stderr")
let browser: Awaited<ReturnType<typeof chromium.connectOverCDP>> | undefined
try {
progress("waiting for CDP", { run })
await waitForCDP(endpoint, child, stdout, stderr)
progress("connecting Playwright", { run })
browser = await chromium.connectOverCDP(endpoint)
progress("waiting for renderer", { run })
const page = await waitForRenderer(browser)
progress("waiting for desktop API", { run })
await page.waitForFunction(() => typeof window.api === "object", undefined, { timeout: 60_000 })
progress("desktop ready", { run })
return await use(page)
} finally {
progress("stopping Electron", { run })
await browser?.close().catch(() => {})
await killTree(child.pid)
await Promise.allSettled([stdout, stderr])
if (database !== options.database) {
await Bun.sleep(500)
await rm(database, { force: true }).catch(() => undefined)
}
}
}
export async function run(command: string[], cwd: string, database: string) {
const child = Bun.spawn(command, {
cwd,
env: { ...process.env, OPENCODE_DB: database, OPENCODE_CHANNEL: "dev" },
stdout: "inherit",
stderr: "inherit",
})
const code = await child.exited
if (code !== 0) throw new Error(`${command.join(" ")} exited with ${code}`)
}
function availablePort() {
const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const port = server.port
server.stop(true)
return port
}
async function waitForCDP(
endpoint: string,
child: ReturnType<typeof Bun.spawn>,
stdout: Promise<string>,
stderr: Promise<string>,
) {
const timeout = Date.now() + 5 * 60_000
let heartbeat = Date.now() + 10_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}`)
if (Date.now() >= heartbeat) {
progress("still waiting for CDP")
heartbeat = Date.now() + 10_000
}
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
let heartbeat = Date.now() + 10_000
while (Date.now() < timeout) {
const page = browser
.contexts()
.flatMap((context) => context.pages())
.find((candidate) => candidate.url().startsWith("oc://renderer"))
if (page) return page
if (Date.now() >= heartbeat) {
progress("still waiting for renderer")
heartbeat = Date.now() + 10_000
}
await Bun.sleep(100)
}
throw new Error("Desktop renderer target was not found")
}
async function drain(stream: ReadableStream<Uint8Array>, label: string) {
const decoder = new TextDecoder()
let output = ""
let pending = ""
for await (const chunk of stream) {
const text = decoder.decode(chunk, { stream: true })
output = (output + text).slice(-50_000)
const lines = (pending + text).split(/\r?\n/)
pending = lines.pop() ?? ""
lines.filter(Boolean).forEach((line) => progress(`Electron ${label}`, { line: line.slice(0, 500) }))
}
if (pending) progress(`Electron ${label}`, { line: pending.slice(0, 500) })
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
}
@@ -1,75 +0,0 @@
import type { Page } from "@playwright/test"
import { progress } from "./progress"
export 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)
}
export async function waitForQuietDOM(page: Page) {
progress("waiting for DOM to settle")
await page.evaluate(
() =>
new Promise<void>((resolve) => {
let settled = false
let timer = setTimeout(done, 750)
const deadline = setTimeout(done, 30_000)
const observer = new MutationObserver(() => {
clearTimeout(timer)
timer = setTimeout(done, 750)
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
function done() {
if (settled) return
settled = true
clearTimeout(deadline)
observer.disconnect()
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
}
}),
)
progress("DOM settled")
}
export async function waitForSelector(page: Page, selector: string, label: string) {
progress("waiting for UI", { label })
try {
await page.waitForSelector(selector, { timeout: 30_000 })
} catch (error) {
progress("UI wait failed", {
label,
url: page.url(),
body: (await page.locator("body").innerText().catch(() => "")).replace(/\s+/g, " ").slice(0, 500),
})
throw error
}
progress("UI ready", { label })
}
export async function domCounts(page: Page, review = false) {
return page.evaluate((review) => ({
elements: document.getElementsByTagName("*").length,
...(review
? {
diffViewers: document.querySelectorAll('[data-component="file"][data-mode="diff"]').length,
diffLines: document.querySelectorAll("[data-line]").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,
}),
}), review)
}
export function sum(values: number[]) {
return values.reduce((total, value) => total + value, 0)
}
export function percentile(values: number[], quantile: number) {
return values.toSorted((a, b) => a - b)[Math.max(0, Math.ceil(values.length * quantile) - 1)] ?? 0
}
@@ -1,178 +0,0 @@
import type { Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { startChromeTrace } from "../chrome-trace"
import { collectProbe, resetProbe, startCPUProfile, summarizeProbe } from "./probe"
import { progress } from "./progress"
import { domCounts, percentile, setDesktopRoute, sum, waitForQuietDOM, waitForSelector } from "./scenario-utils"
import type { Options, Target } from "./types"
export async function runScenarios(page: Page, options: Options, targets: Target[], typingText: string) {
const results: unknown[] = []
if (options.scenarios.includes("home")) results.push(await profileHome(page, options))
if (options.scenarios.includes("calibration")) results.push(await profileCalibration(page))
if (options.scenarios.includes("session")) {
for (const target of targets) results.push(await profileSession(page, options, target))
}
if (options.scenarios.some((scenario) => ["composer", "history", "review"].includes(scenario))) {
await openSession(page, targets[2]!)
}
if (options.scenarios.includes("composer")) results.push(await profileComposer(page, options, typingText))
if (options.scenarios.includes("history")) results.push(await profileHistory(page, options, targets[2]!))
if (options.scenarios.includes("review")) {
const review = await profileReview(page, options)
if (review) results.push(review)
}
return results
}
async function profileHome(page: Page, options: Options) {
const measured = await measure(page, options, "home", async () => {
await setDesktopRoute(page, "/")
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
await waitForQuietDOM(page)
})
return { ...measured, dom: await domCounts(page) }
}
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))
})
}),
)
return { name: "attribution-calibration", ...summarizeProbe(await collectProbe(page)) }
}
async function profileSession(page: Page, options: Options, target: Target) {
await prepareHome(page)
const measured = await measure(page, options, `session-${target.label}`, async () => {
await navigateSession(page, target)
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
await waitForQuietDOM(page)
})
return { ...measured, context: targetContext(target), dom: await domCounts(page) }
}
async function profileComposer(page: Page, options: Options, typingText: string) {
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) => !["\r", "\n", "\t"].includes(character))
const measuredText = printable.slice(-120).join("")
const prefix = printable.slice(0, -measuredText.length).join("")
if (prefix) await page.keyboard.insertText(prefix)
await waitForQuietDOM(page)
const durations: number[] = []
const measured = await measure(page, options, "composer-typing", async () => {
for (const character of measuredText) {
const started = performance.now()
await page.keyboard.type(character)
durations.push(performance.now() - started)
}
await waitForQuietDOM(page)
})
await page.keyboard.press("Control+A")
await page.keyboard.press("Backspace")
return {
...measured,
context: { promptCharacters: printable.length, measuredCharacters: measuredText.length },
typing: {
totalMs: sum(durations),
meanMs: sum(durations) / durations.length,
p50Ms: percentile(durations, 0.5),
p95Ms: percentile(durations, 0.95),
maxMs: Math.max(...durations),
},
}
}
async function profileHistory(page: Page, options: Options, target: Target) {
await waitForSelector(page, '[data-component="prompt-input"]', "history session composer")
await waitForQuietDOM(page)
let requests = 0
const onResponse = (response: { url(): string }) => {
if (/\/session\/[^/]+\/message(?:\?|$)/.test(response.url())) requests++
}
page.on("response", onResponse)
const measured = await measure(page, options, "session-max-history-boundary", async () => {
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }).first()
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)
return { ...measured, context: targetContext(target), messageRequests: requests }
}
async function profileReview(page: Page, options: Options) {
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)
}
const measured = await measure(page, options, "review-open", async () => {
await button.click()
await panel.waitFor({ state: "visible", timeout: 60_000 })
await waitForQuietDOM(page)
})
return { ...measured, dom: await domCounts(page, true) }
}
async function measure(page: Page, options: Options, name: string, action: () => Promise<void>) {
progress("scenario started", { name })
await resetProbe(page)
const stopTrace = options.diagnostics ? await startChromeTrace(page, name) : undefined
const cpu = await startCPUProfile(page, options.cpu)
const started = performance.now()
await action()
const result = {
name,
elapsedMs: performance.now() - started,
...summarizeProbe(await collectProbe(page)),
cpu: await cpu.stop(),
trace: await stopTrace?.(),
}
progress("scenario completed", { name, elapsedMs: Math.round(result.elapsedMs), longTasks: result.longTasks.count })
return result
}
async function openSession(page: Page, target: Target) {
await navigateSession(page, target)
await waitForSelector(page, '[data-component="prompt-input"]', "session composer")
await waitForQuietDOM(page)
}
async function prepareHome(page: Page) {
await setDesktopRoute(page, "/")
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
await waitForSelector(page, '[data-component="home-session-row"]', "Home session rows")
await waitForQuietDOM(page)
}
async function navigateSession(page: Page, target: Target) {
await setDesktopRoute(page, `/server/${base64Encode("sidecar")}/session/${target.id}`)
await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 })
}
function targetContext(target: Target) {
return { serializedBytes: target.bytes, messages: target.messages, parts: target.parts, userTurns: target.userTurns }
}
@@ -1,50 +0,0 @@
export const scenarios = ["home", "calibration", "session", "composer", "history", "review"] as const
export type Scenario = (typeof scenarios)[number]
export type Options = {
mode: "local" | "partial-snapshot"
database: string
output: string
windowStart: number
windowEnd: number
scenarios: Scenario[]
runs: number
build: boolean
diagnostics: boolean
cpu: boolean
responseURLs: boolean
partialSnapshotOut?: string
}
export type Target = {
label: "p50" | "p95" | "max"
id: string
projectID: string
directory: string
title: string
bytes: number
messages: number
parts: number
userTurns: number
}
export 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 }[]
}
@@ -1,107 +0,0 @@
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createPartialSnapshot, fingerprint, loadCorpus } from "./desktop-profile/corpus"
import { parseOptions } from "./desktop-profile/options"
import { installProbe } from "./desktop-profile/probe"
import { progress } from "./desktop-profile/progress"
import { withDesktop, run } from "./desktop-profile/runtime"
import { runScenarios } from "./desktop-profile/scenarios"
const root = path.resolve(import.meta.dir, "../../../..")
const desktop = path.join(root, "packages/desktop")
const options = parseOptions(process.argv.slice(2))
if (!options) process.exit(0)
await mkdir(options.output, { recursive: true })
progress("loading corpus", { mode: options.mode })
let corpus = loadCorpus(options)
if (options.partialSnapshotOut) {
progress("creating partial snapshot")
await createPartialSnapshot(options.database, options.partialSnapshotOut, options, corpus.targets)
options.database = options.partialSnapshotOut
options.mode = "partial-snapshot"
corpus = loadCorpus(options)
}
if (options.build) {
progress("building desktop production bundle")
await run(["bun", "run", "build"], desktop, options.database)
}
progress("corpus ready", { targets: corpus.targets.map((target) => target.label), runs: options.runs })
const runs = []
for (let index = 1; index <= options.runs; index++) {
runs.push(
await withDesktop(options, desktop, index, corpus.projectIDs, async (page) => {
await installProbe(page, options)
await page.evaluate(() => {
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
localStorage.setItem(
"settings.v3",
JSON.stringify({ ...settings, general: { ...settings.general, newLayoutDesigns: true } }),
)
})
return runScenarios(page, options, corpus.targets, corpus.typingText)
}),
)
}
const report = {
schemaVersion: 2,
source: options.mode === "partial-snapshot" ? "partial-database-snapshot" : "local-opencode-db",
command: process.argv.slice(2),
diagnostics: options.diagnostics,
profileCPU: options.cpu,
database: await fingerprint(options.database),
window: {
start: new Date(options.windowStart).toISOString(),
end: new Date(options.windowEnd).toISOString(),
},
revision: (await Bun.$`git rev-parse HEAD`.cwd(root).text()).trim(),
targets: corpus.targets.map(({ id: _, projectID: __, directory: ___, title: ____, ...target }) => target),
summary: summarize(runs),
runs: runs.map((results, index) => ({ index: index + 1, results })),
}
const file = path.join(options.output, "renderer-profile.json")
await Bun.write(file, JSON.stringify(report, null, 2))
console.log(`PROFILE_REPORT ${file}`)
console.log(`PROFILE_SUMMARY ${JSON.stringify(report.summary)}`)
console.log(JSON.stringify(report, null, 2))
function summarize(runs: unknown[][]) {
type Result = {
name: string
elapsedMs?: number
longTasks: { count: number; totalMs: number; maxMs: number }
longAnimationFrames: { totalBlockingMs: number }
typing?: { p50Ms: number; p95Ms: number; maxMs: number }
}
return Object.fromEntries(
[...Map.groupBy(runs.flat() as Result[], (result) => result.name)].map(([name, samples]) => [
name,
{
samples: samples.length,
elapsedMedianMs: median(samples.flatMap((sample) => sample.elapsedMs ?? [])),
longTasks: {
maxCount: Math.max(...samples.map((sample) => sample.longTasks.count)),
maxTotalMs: Math.max(...samples.map((sample) => sample.longTasks.totalMs)),
maxTaskMs: Math.max(...samples.map((sample) => sample.longTasks.maxMs)),
},
maxBlockingMs: Math.max(...samples.map((sample) => sample.longAnimationFrames.totalBlockingMs)),
...(samples[0]?.typing
? {
typingMedianMs: {
p50: median(samples.flatMap((sample) => sample.typing?.p50Ms ?? [])),
p95: median(samples.flatMap((sample) => sample.typing?.p95Ms ?? [])),
max: median(samples.flatMap((sample) => sample.typing?.maxMs ?? [])),
},
}
: {}),
},
]),
)
}
function median(values: number[]) {
if (values.length === 0) return
return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]
}
@@ -1 +0,0 @@
import "./profile-desktop"
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20
const historyPageSize = 50
const historyPageSize = 200
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`,
@@ -1,757 +0,0 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { expect, test, type Page } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
import { installSseTransport } from "../utils/sse-transport"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const root = "C:/OpenCode/WorkspaceProject"
const workspace = "C:/OpenCode/worktree/project/feature"
const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix"
const project = {
id: "proj_workspaces",
worktree: root,
vcs: "git" as const,
name: "workspace-project",
time: { created: 1, updated: 1 },
sandboxes: [workspace],
}
const provider = {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
}
const diff = {
file: "src/workspace.ts",
additions: 3,
deletions: 1,
patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true",
}
function userMessage(sessionID: string, id: string, text: string, withDiff = false) {
return {
info: {
id,
sessionID,
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "opencode", modelID: "test" },
...(withDiff ? { summary: { diffs: [diff] } } : {}),
},
parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }],
}
}
async function init(page: Page, tab: Record<string, unknown>) {
await page.addInitScript(
({ root, server, tab }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
)
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
},
{ root, server, tab },
)
}
test("selects local, new, and existing workspaces from the ready-ish start menu", async ({ page }) => {
const draftID = "draft_workspaces"
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
const trigger = page.getByRole("button", { name: /^local$/i })
await expect(trigger).toBeVisible()
await trigger.hover()
await expect(page.getByRole("tooltip")).toContainText("Select where to run session")
await trigger.click()
await expect(page.getByRole("menuitem", { name: /Local repository/ })).toBeVisible()
const newWorkspace = page.getByRole("menuitem", { name: /New workspace/ })
await expect(newWorkspace).toBeVisible()
await expect(page.getByRole("menuitem", { name: /Workspace/ })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "View all" })).toBeVisible()
await newWorkspace.click()
await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
await expect(page.getByText("from main", { exact: true })).toBeVisible()
await page.getByRole("button", { name: /New workspace/ }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
})
test("searches long workspace lists within the available viewport", async ({ page }) => {
const draftID = "draft_workspace_search"
const workspaces = Array.from({ length: 10 }, (_, index) => `${workspace}-${index}`)
await mockOpenCodeServer(page, {
directory: root,
project: { ...project, sandboxes: workspaces },
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await init(page, { type: "draft", draftID, directory: root })
await page.setViewportSize({ width: 600, height: 360 })
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).focus()
await page.keyboard.press("ArrowRight")
const submenu = page
.locator('[data-component="menu-v2-content"]')
.filter({ has: page.getByPlaceholder("Search workspaces") })
const search = submenu.getByPlaceholder("Search workspaces")
await expect(search).toBeFocused()
const box = await submenu.boundingBox()
expect(box).not.toBeNull()
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.y + box!.height).toBeLessThanOrEqual(360)
await search.fill("feature-9")
await expect(submenu.getByRole("menuitem", { name: "feature-9" })).toBeVisible()
await expect(submenu.getByRole("menuitem", { name: "feature-0" })).toHaveCount(0)
})
test("lists and manually deletes workspaces from settings", async ({ page }) => {
const draftID = "draft_workspace_settings"
const cleanWorkspace = `${workspace}-clean`
const inventory = { ...project, sandboxes: [workspace, cleanWorkspace] }
const session = {
id: "ses_workspace_settings",
slug: "workspace-settings",
projectID: project.id,
directory: workspace,
title: "Workspace settings session",
version: "dev",
time: { created: 1, updated: 2 },
}
let removal: { directory: string | null; body: unknown } | undefined
let statusRequests = 0
let sessionListRequests = 0
page.on("request", (request) => {
const path = new URL(request.url()).pathname
if (path === "/vcs/status" || path === "/api/vcs/status") statusRequests++
if (path === "/session" || path === "/api/session") sessionListRequests++
})
await mockOpenCodeServer(page, {
directory: root,
project: inventory,
provider,
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
})
return
}
if (route.request().method() !== "DELETE") return route.fallback()
const url = new URL(route.request().url())
removal = { directory: url.searchParams.get("directory"), body: route.request().postDataJSON() }
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "true",
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
await expect(settings.getByText("Automatically delete old workspaces")).toHaveCount(0)
const firstSessionInventory = sessionListRequests
await page.keyboard.press("Escape")
await expect(settings).toHaveCount(0)
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "View all" }).click()
await expect(settings.getByText("Workspace settings session", { exact: true })).toBeVisible()
expect(sessionListRequests).toBeGreaterThan(firstSessionInventory)
await page.setViewportSize({ width: 375, height: 700 })
expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
await page.setViewportSize({ width: 1280, height: 720 })
await settings.getByRole("button", { name: "More options" }).click()
await page.getByRole("menuitem", { name: "Delete all workspaces" }).click()
const bulkConfirmation = page.locator('[data-dialog-layer="1"]')
await expect(bulkConfirmation.getByText("Delete all 2 workspaces?")).toBeVisible()
await expect(bulkConfirmation).toContainText("All projects")
await bulkConfirmation.getByRole("button", { name: "Cancel" }).click()
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
let confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
await expect(confirmation).toContainText("linked sessions")
await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled()
const firstInspection = statusRequests
await confirmation.getByRole("button", { name: "Cancel" }).click()
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
await expect(confirmation).toContainText("linked sessions")
expect(statusRequests).toBeGreaterThan(firstInspection)
await confirmation.getByRole("button", { name: "Cancel" }).click()
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature-clean"?' })
await expect(confirmation).toContainText("permanently removed")
const beforeRemoval = statusRequests
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
await expect.poll(() => removal).toEqual({ directory: root, body: { directory: cleanWorkspace } })
expect(statusRequests).toBeGreaterThan(beforeRemoval)
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
})
test("blocks deletion of the currently active workspace", async ({ page }) => {
const draftID = "draft_workspace_active"
let removed = false
await mockOpenCodeServer(page, {
directory: workspace,
project,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() !== "DELETE") return route.fallback()
removed = true
await route.fulfill({ status: 200, contentType: "application/json", body: "true" })
})
await init(page, { type: "draft", draftID, directory: workspace })
await page.goto(`/new-session?draftId=${draftID}`)
await page.getByRole("button", { name: /feature/ }).click()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await settings.getByRole("button", { name: 'Delete workspace "feature"?' }).click()
const confirmation = page.locator('[data-component="dialog-v2"]').filter({ hasText: 'Delete workspace "feature"?' })
await expect(confirmation).toContainText("active workspace")
await expect(confirmation.getByRole("button", { name: "Delete workspace" })).toBeDisabled()
expect(removed).toBe(false)
})
test("wraps the workspace toolbar for long project filters on mobile", async ({ page }) => {
const draftID = "draft_workspace_mobile_filter"
const other = {
...project,
id: "proj_workspaces_other",
name: "A second project with a deliberately long workspace filter label",
worktree: "C:/OpenCode/AnotherWorkspaceProject",
sandboxes: ["C:/OpenCode/worktree/another/feature-with-a-long-name"],
}
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route("**/project**", async (route) => {
if (new URL(route.request().url()).pathname !== "/project") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify([project, other]),
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.setViewportSize({ width: 375, height: 700 })
await page.goto(`/new-session?draftId=${draftID}`)
const dismissTabs = page.getByRole("button", { name: "Dismiss Tabs information" })
if (await dismissTabs.isVisible()) await dismissTabs.click()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await expect(settings.getByRole("button", { name: "All projects" })).toBeVisible()
expect(await settings.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(true)
})
test("bulk deletion snapshots inventory and skips dirty or unknown workspaces", async ({ page }) => {
const draftID = "draft_workspace_delete_all"
const unknown = `${workspace}-unknown`
const clean = `${workspace}-clean`
const later = `${workspace}-later`
const inventory = { ...project, sandboxes: [workspace, unknown, clean] }
const removals: string[] = []
let release = () => {}
const deleteGate = new Promise<void>((resolve) => {
release = resolve
})
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project: inventory,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.route("**/vcs/status**", async (route) => {
const directory = new URL(route.request().url()).searchParams.get("directory")
if (directory === workspace) {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify([{ path: "dirty.ts", status: "modified" }]),
})
return
}
if (directory === unknown) {
await route.fulfill({
status: 500,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ message: "status failed" }),
})
return
}
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "[]",
})
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
})
return
}
if (route.request().method() !== "DELETE") return route.fallback()
const body = route.request().postDataJSON() as { directory: string }
removals.push(body.directory)
await deleteGate
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "true",
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await settings.getByRole("button", { name: "More options" }).click()
await page.getByRole("menuitem", { name: "Delete all workspaces" }).click()
const confirmation = page.locator('[data-dialog-layer="1"]')
await expect(confirmation).toContainText("Delete all 3 workspaces?")
await transport.send({
directory: "global",
payload: {
id: "evt_workspace_added_after_confirmation",
type: "project.updated",
properties: { ...inventory, sandboxes: [...inventory.sandboxes, later] },
},
})
await confirmation.getByRole("button", { name: "Delete all workspaces" }).click()
await expect.poll(() => removals).toEqual([clean])
const deleteButtons = settings.getByRole("button", { name: /Delete workspace/ })
await expect.poll(() => deleteButtons.count()).toBeGreaterThan(0)
expect(await deleteButtons.evaluateAll((buttons) => buttons.every((button) => button.hasAttribute("disabled")))).toBe(
true,
)
release()
await expect(settings.getByText(workspace, { exact: true })).toBeVisible()
await expect(settings.getByText(unknown, { exact: true })).toBeVisible()
await expect(settings.getByText(clean, { exact: true })).toHaveCount(0)
await expect(settings.getByText(later, { exact: true })).toBeVisible()
})
test("applies the recovered new-workspace default without automatic cleanup", async ({ page }) => {
const draftID = "draft_workspace_default"
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await page.addInitScript(
({ draftID, root, server }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
general: { newLayoutDesigns: true },
workspaces: { defaultDestination: "new", lastUsed: {} },
}),
)
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory: root }]),
)
},
{ draftID, root, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
await expect(page.getByText("from main", { exact: true })).toBeVisible()
})
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
const draftID = "draft_workspace_submit"
const sessionID = "ses_workspace_submit"
const session = {
id: sessionID,
slug: "workspace-submit",
projectID: project.id,
directory: createdWorkspace,
title: "New session",
version: "dev",
time: { created: 1, updated: 2 },
}
let prompt: unknown
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
})
})
await page.route("**/session**", async (route) => {
const url = new URL(route.request().url())
if (url.pathname !== "/session") return route.fallback()
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(session),
})
})
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
prompt = route.request().postDataJSON()
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*" },
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
await editor.fill("Build workspace support")
await page.locator('[data-action="prompt-submit"]').click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
for (const attempt of [1, 2, 3, 4, 5]) {
await transport.send({
directory: createdWorkspace,
payload: {
id: `evt_submit_ready_${attempt}`,
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await page.waitForTimeout(100)
if (prompt) break
}
await expect.poll(() => prompt).not.toBeUndefined()
await expect(lifecycle).toContainText("Workspace created")
})
test("shows neutral workspace identity and the ready-ish session summary panel", async ({ page }) => {
const sessionID = "ses_workspace_summary"
const messageID = "msg_workspace_summary"
const session = {
id: sessionID,
slug: "workspace-summary",
projectID: project.id,
directory: workspace,
title: "Workspace summary session",
version: "dev",
time: { created: 1, updated: 2 },
}
const vcsRequests: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/vcs/diff") vcsRequests.push(request.url())
})
await mockOpenCodeServer(page, {
protocol: "v2",
directory: workspace,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Implement workspace support", true)] }),
vcsDiff: [diff],
})
await init(page, { type: "session", sessionId: sessionID })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const timeline = page.locator("[data-workspace-session]")
await expect(timeline).toBeVisible()
await expect(timeline.locator(`[aria-label="${workspace}"]`)).toHaveAttribute("tabindex", "0")
await expect(timeline.locator('[data-slot="session-title-child"]')).toHaveClass(/text-v2-text-text-base/)
await expect(timeline.locator('[data-slot="user-message-text"]')).not.toHaveCSS(
"background-color",
"rgb(59, 92, 246)",
)
const title = page.locator("[data-session-title]")
await title.getByRole("button", { name: "Session details" }).click()
const panel = page.locator('[data-component="session-summary-panel"]')
await expect(panel).toBeVisible()
await expect(panel).toContainText("workspace-project")
await expect(panel).toContainText("feature")
await expect(panel).toContainText("1 Changed file")
await expect.poll(() => vcsRequests.length).toBeGreaterThan(0)
const request = new URL(vcsRequests.at(-1)!)
expect(request.searchParams.get("mode")).toBe("working")
expect(request.searchParams.get("location[directory]")).toBe(workspace)
})
test("moves a changed local session to an existing workspace with an end-of-turn divider", async ({ page }) => {
const sessionID = "ses_workspace_move_existing"
const messageID = "msg_workspace_move_existing"
const session = {
id: sessionID,
slug: "workspace-move-existing",
projectID: project.id,
directory: root,
title: "Move this session",
version: "dev",
time: { created: 1, updated: 2 },
}
let move: unknown
let releaseMove = () => {}
const moveGate = new Promise<void>((resolve) => {
releaseMove = resolve
})
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Move this work", true)] }),
vcsDiff: [diff],
})
await page.route("**/experimental/control-plane/move-session", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
move = route.request().postDataJSON()
await moveGate
session.directory = workspace
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "null",
})
})
await init(page, { type: "session", sessionId: sessionID })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await transport.waitForConnection()
const inlineMove = page
.locator('[data-component="session-turn-diffs-group"]')
.getByRole("button", { name: "Move to workspace" })
await expect(inlineMove).toBeVisible()
await inlineMove.click()
await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect
.poll(() => move)
.toEqual({
sessionID,
destination: { directory: workspace },
moveChanges: true,
})
await page.locator("[data-session-title]").getByRole("button", { name: "More options" }).click()
await expect(page.getByRole("menuitem", { name: "Archive" })).toBeDisabled()
await expect(page.getByRole("menuitem", { name: /Delete/ })).toBeDisabled()
await page.keyboard.press("Escape")
releaseMove()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Workspace set")
})
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
page,
}) => {
const sessionID = "ses_workspace_move_new"
const messageID = "msg_workspace_move_new"
const session = {
id: sessionID,
slug: "workspace-move-new",
projectID: project.id,
directory: root,
title: "Create a workspace",
version: "dev",
time: { created: 1, updated: 2 },
}
let move: unknown
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
vcsDiff: [diff],
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
})
})
await page.route("**/experimental/control-plane/move-session", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
move = route.request().postDataJSON()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "null",
})
})
await init(page, { type: "session", sessionId: sessionID })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await transport.waitForConnection()
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
const panel = page.locator('[data-component="session-summary-panel"]')
await panel.getByRole("button", { name: "Local repository" }).click()
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
for (const attempt of [1, 2, 3, 4, 5]) {
await transport.send({
directory: createdWorkspace,
payload: {
id: `evt_worktree_ready_${attempt}`,
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await page.waitForTimeout(100)
if (move) break
}
await expect
.poll(() => move)
.toEqual({
sessionID,
destination: { directory: createdWorkspace },
moveChanges: true,
})
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_workspace_created",
type: "session.next.moved",
properties: {
timestamp: Date.now(),
sessionID,
location: { directory: createdWorkspace },
subdirectory: "",
},
},
})
await expect(lifecycle).toContainText("Workspace created")
})
+1 -2
View File
@@ -28,8 +28,7 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report",
"test:stability": "bun test ./e2e/performance/unit/visual-stability.test.ts && playwright test --config e2e/performance/timeline-stability/playwright.config.ts",
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts",
"profile:desktop": "bun run e2e/performance/profile-desktop.ts"
"test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
},
"license": "MIT",
"devDependencies": {
+7 -12
View File
@@ -3,6 +3,7 @@ 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"
@@ -57,24 +58,18 @@ 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}
delay="intent"
openDelay={0}
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}
delay="intent"
openDelay={0}
value={
<ModelTooltip
model={item}
@@ -10,6 +10,9 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.png"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
const settings = useSettings()
@@ -131,6 +134,7 @@ export function TabsInfoPopup() {
<p>{language.t("help.tabs.home")}</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>{language.t("help.tabs.persistence")}</p>
<p>{language.t("help.tabs.worktrees")}</p>
</div>
</div>
</DrawerContent>
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span>
}
placement="top"
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})}
openDelay={800}
>
<div
classList={{
@@ -53,7 +53,12 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}>
{(item) => (
<div class="relative group shrink-0">
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
<TooltipV2
value={item.comment}
placement="top"
openDelay={800}
contentClass="max-w-[300px] break-words"
>
<CommentCardV2
comment={item.comment ?? ""}
path={item.path}
@@ -2,9 +2,6 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
@@ -29,17 +26,12 @@ const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
const sentCommandDirectories: string[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {}
@@ -48,18 +40,6 @@ let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let sessionFailure: Error | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let worktreeHung = false
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const draftServers: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -87,21 +67,16 @@ const prompt = {
replaceComments: () => undefined,
items: () => [],
},
capture: (scope?: unknown, target?: unknown) => {
promptCaptures.push({ scope, target })
return prompt
},
capture: () => prompt,
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string, track = true) => {
if (track) createdClients.push(directory)
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
if (sessionFailure) throw sessionFailure
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
@@ -124,11 +99,9 @@ const clientFor = (directory: string, track = true) => {
},
command: async (input: unknown) => {
sentCommands.push(input)
sentCommandDirectories.push(directory)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
sentShellDirectories.push(directory)
},
},
},
@@ -137,16 +110,7 @@ const clientFor = (directory: string, track = true) => {
abort: async () => ({ data: undefined }),
},
worktree: {
create: async (_input: unknown, options?: { signal?: AbortSignal }) => {
worktreeCreates++
if (worktreeHung)
return new Promise<never>((_, reject) => {
options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true })
})
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { data: { directory: worktreeDirectory } }
},
create: async () => ({ data: { directory: `${directory}/new` } }),
},
}
}
@@ -209,10 +173,7 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({
useTabs: () => ({
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
draft: () => ({ server: "project-server" }),
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session })
},
@@ -233,81 +194,72 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({
useSDK: () => {
return () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
const sdk = {
scope: "local",
directory: "/repo/main",
client: rootClient,
api: rootClient.api,
url: "http://localhost:4096",
createApi: (directory: string) => clientFor(directory, false).api,
createClient(opts: any) {
return clientFor(opts.directory)
},
})
}
return () => sdk
},
}))
mock.module("@/context/sync", () => ({
useSync: () => () => {
const server = activeDirectorySync
return {
data: { command: commands },
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => {
optimisticServers.push(server)
optimistic.push(value)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
},
remove: () => undefined,
useSync: () => () => ({
data: { command: commands },
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => {
optimistic.push(value)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
},
remove: () => undefined,
},
set: () => undefined,
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
},
set: () => undefined,
}),
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => () => {
const server = activeServerSync
return {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
useServerSync: () => () => ({
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
},
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
},
child: (directory: string) => {
syncedServers.push(server)
syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
},
]
},
}
},
]
},
}),
}))
mock.module("@/context/platform", () => ({
@@ -335,204 +287,24 @@ beforeEach(() => {
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentCommandDirectories.length = 0
sentPrompts.length = 0
promptInputs.length = 0
syncedServers.length = 0
optimisticServers.length = 0
promptCaptures.length = 0
sentCommands.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
search = {}
sentShell.length = 0
sentShellDirectories.length = 0
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
permissionServer = "server-a"
activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined
serverSessionSyncs = 0
sessionFailure = undefined
createWorktreeGate = undefined
worktreeFailure = undefined
worktreeHung = false
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
})
test("allows retry after new-workspace submission fails", async () => {
selected = "create"
worktreeFailure = new Error("create failed")
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
worktreeFailure = undefined
await submit.handleSubmit(event)
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(2)
expect(createdSessions).toEqual([worktreeDirectory])
expect(sentPrompts).toEqual([worktreeDirectory])
})
test("aborts a hung new-workspace request and allows retry", async () => {
selected = "create"
worktreeHung = true
let resets = 0
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => resets++,
onSubmit: () => undefined,
worktreeRequestTimeoutMs: 1,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
expect(selected).toBe("create")
expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }])
expect(resets).toBe(0)
worktreeHung = false
await submit.handleSubmit(event)
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(2)
expect(createdSessions).toEqual([worktreeDirectory])
expect(sentPrompts).toEqual([worktreeDirectory])
expect(resets).toBe(1)
})
test("retains workspace selection after session creation failure and clears it after retry", async () => {
sessionFailure = new Error("session create failed")
let resets = 0
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => {
resets++
selected = "main"
},
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(resets).toBe(0)
expect(selected).toBe("/repo/worktree-a")
sessionFailure = undefined
await submit.handleSubmit(event)
await settle()
expect(resets).toBe(1)
expect(selected).toBe("main")
})
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
@@ -648,54 +420,6 @@ describe("prompt submit worktree selection", () => {
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
let submitted = 0
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => submitted++,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined()
expect(submitted).toBe(0)
})
test("promotes drafts using the selected project's server", async () => {
search = { draftId: "draft-1" }
const submit = createPromptSubmit({
@@ -724,7 +448,7 @@ describe("prompt submit worktree selection", () => {
})
test("includes the selected variant on optimistic prompts", async () => {
params.id = "session-1"
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
@@ -871,154 +595,4 @@ describe("prompt submit worktree selection", () => {
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
})
test("waits for a new workspace before sending an initial shell", async () => {
selected = "create"
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentShell).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(sentShell[0]).toMatchObject({
sessionID: "session-1",
command: "ls",
agent: "agent",
model: { providerID: "provider", modelID: "model" },
})
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete")
})
test("waits for a new workspace before sending an initial custom command", async () => {
selected = "create"
commands = [{ name: "deploy" }]
promptValue = [{ type: "text", content: "/deploy now", start: 0, end: 11 }]
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentCommands).toEqual([])
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(sentCommands).toHaveLength(1)
expect(sentCommands[0]).toMatchObject({
sessionID: "session-1",
command: "deploy",
arguments: "now",
agent: "agent",
model: { id: "model", providerID: "provider" },
files: [],
})
expect(sentCommandDirectories).toEqual([worktreeDirectory])
})
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
selected = "create"
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
params = { id: "session-1" }
await submit.abort()
await settle()
expect(sentPrompts).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
status: "failed",
message: "aborted",
})
})
test("settles a pending workspace operation when preparation fails", async () => {
selected = "create"
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
Worktree.failed(ServerScope.local, worktreeDirectory, "bootstrap failed")
await settle()
expect(sentShell).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
status: "failed",
message: "bootstrap failed",
})
})
})
+266 -365
View File
@@ -15,12 +15,10 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { ScopedKey } from "@/utils/server-scope"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
@@ -29,13 +27,9 @@ import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
scope: ServerScope
sessionID: string
serverSync: ServerSync
}
const pending = new Map<string, PendingPrompt>()
const submitting = new Set<string>()
export type FollowupDraft = {
sessionID: string
@@ -49,7 +43,6 @@ export type FollowupDraft = {
type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
scope: ServerScope
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
@@ -63,8 +56,6 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ?
const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) {
const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID)
if (operation?.status === "pending" && operation.messageID !== input.messageID) return false
const text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt)
const setBusy = () => {
@@ -238,7 +229,6 @@ type PromptSubmitInput = {
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
worktreeRequestTimeoutMs?: number
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -254,8 +244,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
@@ -268,23 +257,18 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const abort = async () => {
const routeSessionID = params.id
const owned =
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
? pending.get(pendingSubmission.key)
: undefined
const sessionID = routeSessionID ?? owned?.sessionID
const sessionID = params.id
if (!sessionID) return Promise.resolve()
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
serverSync().session.set("todo", sessionID, [])
input.onAbort?.()
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID)
const queued = owned ?? pending.get(key)
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
WorkspaceOperation.fail(queued.scope, queued.sessionID, "aborted")
pending.delete(key)
return Promise.resolve()
}
@@ -316,9 +300,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (target: ServerSync, dir: string, info: Session) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
const seed = (dir: string, info: Session) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -350,7 +334,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort()
return
}
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
@@ -364,377 +347,295 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const submissionSDK = sdk()
const submissionSync = sync()
const submissionServerSync = serverSync()
const submissionScope = submissionSDK.scope
const projectDirectory = submissionSDK.directory
const projectRoot = submissionSync.project?.worktree ?? projectDirectory
const sessionID = params.id
const isNewSession = !sessionID
const currentSession = input.info()
const draftID = search.draftId
const draftServer = draftID ? tabs.draft(draftID).server : undefined
const capturePrompt = prompt.capture
const localSession = local.session
const handoff = layout.handoff
const resetWorktree = input.onNewSessionWorktreeReset
const onSubmit = input.onSubmit
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
submissionScope,
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
)
if (submitting.has(submissionKey)) return
submitting.add(submissionKey)
try {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
let sessionDirectory = projectDirectory
let client = sdk().client
let sessionDirectory = projectDirectory
let client = submissionSDK.client
let api = submissionSDK.api.session
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await workspaceRequestWithTimeout(
(signal) => client.worktree.create({ directory: projectDirectory }, { signal }),
language.t("prompt.toast.worktreeCreateFailed.title"),
input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree?.directory) {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: language.t("common.requestFailed"),
})
return
}
WorktreeState.pending(submissionScope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
client = submissionSDK.createClient({
directory: sessionDirectory,
throwOnError: true,
})
api = submissionSDK.createApi(sessionDirectory).session
submissionServerSync.child(sessionDirectory)
}
}
let session = currentSession
if (!session && isNewSession) {
const created = await submissionSDK.api.session
.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (created) {
seed(submissionServerSync, sessionDirectory, created)
session = created
await startTransition(() => {
if (!session) return
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
if (!draftID) resetWorktree?.()
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
localSession.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
handoff.setTabs(base64Encode(sessionDirectory), session.id)
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(
capturePrompt(
{ dir: base64Encode(sessionDirectory), id: session.id },
{ server: draftServer, scope: submissionScope },
),
)
if (!createdWorktree?.directory) {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: language.t("common.requestFailed"),
})
}
}
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
const startWorkspaceOperation = (messageID: string) => {
if (!isNewSession) return
if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) {
WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID)
WorkspaceOperation.complete(submissionScope, session.id)
}
if (worktreeSelection !== "create") return
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID)
if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id)
if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id, worktree.message)
}
const waitForWorktree = async (cleanup: VoidFunction) => {
const worktree = WorktreeState.get(submissionScope, sessionDirectory)
if (!worktree) return true
if (worktree.status === "ready") {
WorkspaceOperation.complete(submissionScope, session.id)
return true
}
if (worktree.status === "failed") {
WorkspaceOperation.fail(submissionScope, session.id, worktree.message)
throw new Error(worktree.message)
}
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const key = pendingKey(submissionScope, session.id)
pendingSubmission = { key, scope: submissionScope, sessionID: session.id }
pending.set(key, {
abort: controller,
cleanup,
scope: submissionScope,
sessionID: session.id,
serverSync: submissionServerSync,
})
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return
}
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})
const result = await Promise.race([
WorktreeState.wait(submissionScope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
pending.delete(key)
if (pendingSubmission?.key === key) pendingSubmission = undefined
if (timer.id === undefined) return
clearTimeout(timer.id)
})
if (controller.signal.aborted) return false
if (result.status === "failed") {
WorkspaceOperation.fail(submissionScope, session.id, result.message)
throw new Error(result.message)
}
WorkspaceOperation.complete(submissionScope, session.id)
return true
}
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
startWorkspaceOperation(eventID)
void waitForWorktree(() => {
restoreInput()
})
.then((ready) => {
if (!ready) return
return api.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
})
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
startWorkspaceOperation(messageID)
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void waitForWorktree(() => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
restoreInput()
})
.then(async (ready) => {
if (!ready) return
return api.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
})
.catch((err) => {
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
startWorkspaceOperation(messageID)
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
if (sessionDirectory !== projectDirectory) {
client = sdk().createClient({
directory: sessionDirectory,
sessionID: session.id,
messageID,
throwOnError: true,
})
serverSync().child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
}
let session = input.info()
if (!session && isNewSession) {
const created = await sdk()
.api.session.create({
agent: currentAgent.name,
model: { id: currentModel.id, providerID: currentModel.provider.id, variant },
location: { directory: sessionDirectory },
})
.then(normalizeSessionInfo)
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (created) {
seed(sessionDirectory, created)
session = created
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
})
}
}
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
return
}
for (const item of commentItems) submission.target().context.remove(item.key)
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
agent,
model,
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync().data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id,
id: messageID,
command: commandName,
arguments: args.join(" "),
agent,
model: { id: model.modelID, providerID: model.providerID, variant },
files: await Promise.all(
images.map(async (attachment) => ({
uri: await blobDataUrl(attachment.blob, attachment.mime),
name: attachment.filename,
})),
),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
})
restoreInput()
})
return
}
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
sync().session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" })
sync().set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}
void sendFollowupDraft({
api,
scope: submissionScope,
sync: submissionSync,
serverSync: submissionServerSync,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: () => waitForWorktree(cleanup),
}).catch((err) => {
pending.delete(pendingKey(submissionScope, session.id))
if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" })
pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
} finally {
submitting.delete(submissionKey)
const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})
const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
}
void sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
before: waitForWorktree,
}).catch((err) => {
pending.delete(pendingKey(session.id))
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: errorMessage(err),
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
}
return {
@@ -1,7 +1,8 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
@@ -10,46 +11,29 @@ export function PromptWorkspaceSelector(props: {
projectRoot: string
workspaces: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const [search, setSearch] = createSignal("")
let searchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
let pending: string | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
return "workspace"
}
const select = (value: string) => {
pending = { type: "select", value }
pending = value
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch("")
return
}
const action = pending
if (open) return
const value = pending
pending = undefined
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
if (value) props.onChange(value)
props.onDone()
}
const label = () => {
if (selected() === "main") return language.t("workspace.type.local")
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
@@ -57,161 +41,67 @@ export function PromptWorkspaceSelector(props: {
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={language.t("session.new.workspace.trigger.tooltip")}
class="min-w-0"
>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span aria-hidden="true" class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent" />
</Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
placement="right"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<TooltipV2
placement="right"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
overlap
overflowPadding={8}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/…$/, "")}
</span>
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search()}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} connected={selected() === "create"} />
<MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<PromptGitStatus branch={props.branch} />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; connected?: boolean }) {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch
}
@@ -219,24 +109,15 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Show when={label()}>
{(value) => (
<>
<Show when={!props.connected}>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
</Show>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div
class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]"
classList={{ "ml-0.5": props.connected }}
>
<Icon
name={props.noGit ? "monitor" : "branch"}
size="small"
class="shrink-0 text-v2-icon-icon-muted"
/>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
@@ -1,193 +0,0 @@
import type { Project } from "@opencode-ai/sdk/v2/client"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { pathKey } from "@/utils/path-key"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { showToast } from "@/utils/toast"
import type { ServerScope } from "@/utils/server-scope"
import { workspaceDirectories } from "@/utils/workspace"
import {
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
WORKSPACE_PREPARATION_TIMEOUT_MS,
workspaceRequestWithTimeout,
} from "@/utils/workspace-request"
export function SessionWorkspaceMenu(props: {
eligible?: boolean
sessionID: string
project: Project
directory: string
messageID?: string
placement?: ComponentProps<typeof MenuV2>["placement"]
gutter?: number
class?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
const blocked = () =>
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
const workspaces = () =>
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
const fail = (scope: ServerScope, sessionID: string, message: string) => {
setStore("selected", undefined)
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
WorkspaceOperation.fail(scope, sessionID, message)
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK()
const sync = serverSync()
const scope = sdk.scope
const sessionID = props.sessionID
const messageID = props.messageID
const root = props.project.worktree
const source = props.directory
setStore("selected", selection)
const destination =
selection === "create"
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
stillPreparing: language.t("workspace.error.stillPreparing"),
})
: selection
if (!destination) return
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
if (sync.session.data.session_working(sessionID)) {
fail(scope, sessionID, language.t("workspace.move.failed"))
return
}
await workspaceRequestWithTimeout(
(signal) =>
sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory: destination },
moveChanges: true,
},
{ signal },
),
language.t("workspace.move.failed"),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then(async () => {
for (const attempt of Array.from({ length: 20 }, (_, index) => index)) {
const session = await workspaceRequestWithTimeout(
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
language.t("workspace.move.failed"),
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
).catch(() => undefined)
if (session && pathKey(session.directory) === pathKey(destination)) {
WorkspaceOperation.complete(scope, sessionID, destination)
sync.reindexSession(sessionID, source)
return
}
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
await new Promise((resolve) => setTimeout(resolve, Math.min(250 + attempt * 50, 1_000)))
}
fail(scope, sessionID, language.t("workspace.move.failed"))
})
.catch((error) =>
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")),
)
}
return (
<MenuV2
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={props.onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.title")}</MenuV2.GroupLabel>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
</MenuV2.Group>
<Show when={workspaces().length > 0}>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace" />
{language.t("session.new.workspace.existing").replace(/…$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="w-[200px]">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
async function createWorkspace(
root: string,
sessionID: string,
messageID: string | undefined,
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
fail: (message: string) => void,
messages: { createFailed: string; stillPreparing: string },
) {
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
const created = await workspaceRequestWithTimeout(
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
messages.createFailed,
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then((result) => result.data)
.catch((error) => {
fail(error instanceof Error ? error.message : messages.createFailed)
return undefined
})
if (!created?.directory) return
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
Worktree.pending(serverSDK.scope, created.directory)
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
timer.id = setTimeout(
() => resolve({ status: "failed", message: messages.stillPreparing }),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
})
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
if (timer.id) clearTimeout(timer.id)
})
if (!ready || ready.status === "failed") {
fail(ready?.message ?? messages.createFailed)
return
}
return created.directory
}
@@ -7,7 +7,6 @@ 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"
@@ -44,7 +43,6 @@ export const SettingsModels: Component = () => {
const SettingsModelsContent: Component = () => {
const language = useLanguage()
const models = useModels()
useServerSync()().loadProviders()
const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(),
@@ -10,8 +10,8 @@ import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import "./settings-v2.css"
import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
@@ -22,16 +22,18 @@ export const DialogSettings: Component<{
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => {
const active = tabs.active()
if (active?.type === "draft") return active.directory
if (active?.type === "session") {
const session = serverSync().session.get(active.sessionId)
if (session) return session.directory
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined
})
@@ -69,10 +71,6 @@ export const DialogSettings: Component<{
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="workspaces">
<Icon name="fork" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers">
<Icon name="server" />
{language.t("status.popover.tab.servers")}
@@ -101,9 +99,6 @@ export const DialogSettings: Component<{
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
@@ -8,7 +8,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action"
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
import { useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
@@ -87,34 +87,6 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
)
}
const WorkspaceDestinationSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
{ value: "local", label: language.t("settings.workspaces.default.local") },
{ value: "new", label: language.t("settings.workspaces.default.new") },
])
return (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
@@ -357,7 +329,6 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2>
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} />
@@ -9,7 +9,6 @@ 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"
@@ -24,7 +23,6 @@ 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> }),
@@ -684,189 +684,6 @@
color: var(--v2-text-text-base);
}
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 32px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 13px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 6px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: var(--font-family-mono);
font-size: 12px;
font-weight: 440;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta,
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-active {
flex-shrink: 0;
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
overflow: hidden;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-v2-workspaces-session-active {
flex-shrink: 0;
color: var(--v2-text-text-muted);
font-size: 11px;
line-height: 14px;
}
.settings-v2-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
@media (max-width: 639px) {
.settings-v2-workspaces-header {
padding: 24px 20px 20px;
}
.settings-v2-tab-body.settings-v2-workspaces {
padding: 0 20px 24px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
align-items: flex-start;
}
.settings-v2-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-v2-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
padding: 14px;
}
.settings-v2-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-v2-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);
@@ -1,476 +0,0 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { Project, Session } from "@opencode-ai/sdk/v2/client"
import { useQuery } from "@tanstack/solid-query"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { showToast } from "@/utils/toast"
import { getRelativeTime } from "@/utils/time"
import { pathKey } from "@/utils/path-key"
import { SettingsListV2 } from "./parts/list"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/server"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
runWorkspaceDeleteTransaction,
sessionsForWorkspace,
type WorkspaceDeleteTransaction,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
import { listAllSessions, normalizeSessionInfo } from "@/utils/session"
import type { ServerScope } from "@/utils/server-scope"
import "./settings-v2.css"
type Workspace = {
directory: string
project: Project
}
export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const tabs = useTabs()
const platform = usePlatform()
const [store, setStore] = createStore({
project: "all",
deleting: undefined as string | undefined,
deletingAll: false,
transaction: undefined as WorkspaceDeleteTransaction,
})
const workspaces = createMemo(() => workspaceInventory(serverSync().data.project))
const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length))
const projectName = (project: Project) => project.name || getFilename(project.worktree)
const projectOptions = createMemo(() => [
{ id: "all", label: language.t("settings.workspaces.filter.all") },
...projects().map((project) => ({ id: project.id, label: projectName(project) })),
])
const selectedProject = createMemo(() =>
store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all",
)
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
const captureDeleteContext = () => {
const sdk = serverSDK()
return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
}
const loadSessions = async (context = captureDeleteContext()) => {
const protocol = await context.sdk.protocol
const fetched =
protocol === "v1"
? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => {
if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory")
return response.data.map(normalizeSessionInfo)
})
: await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is Session => !!session),
)
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
const workspaceSessions = (workspace: Workspace) => sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory)
const sessionCount = (workspace: Workspace) => {
if (sessionQuery.isPending) return language.t("session.messages.loading")
if (sessionQuery.isError) return language.t("common.requestFailed")
const count = workspaceSessions(workspace).length
return language.t(count === 1 ? "settings.workspaces.sessions.one" : "settings.workspaces.sessions.other", {
count,
project: projectName(workspace.project),
})
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
if (!updated) return undefined
return getRelativeTime(new Date(updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [status, sessions] = await Promise.all([
Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
])
.then(([working, branch]) =>
working.data.length > 0 || branch.data.length > 0 ? ("dirty" as const) : ("clean" as const),
)
.catch(() => "unknown" as const),
loadSessions(context).catch(() => undefined),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
sessions: sessions ?? [],
status: sessions ? status : "unknown",
})
return { result, sessions: sessions ?? [] }
}
const inspectionMessage = (result: WorkspaceDeleteInspection) => {
if (result === "active") return language.t("settings.workspaces.delete.blocked.active")
if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked")
if (result === "dirty") return language.t("workspace.status.dirty")
if (result === "unknown") return language.t("workspace.status.error")
return language.t("workspace.status.clean")
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: inspectionMessage(result),
})
}
const remove = async (workspace: Workspace, bulk = false, context = captureDeleteContext()) => {
if (!bulk) setStore("deleting", pathKey(workspace.directory))
const preflight = await inspect(workspace, context)
if (preflight.result !== "safe" && (bulk || preflight.result !== "dirty")) {
blocked(preflight.result)
if (!bulk) setStore("deleting", undefined)
return
}
const removed = await context.sdk.client.worktree
.remove({
directory: workspace.project.worktree,
worktreeRemoveInput: { directory: workspace.directory },
})
.then((result) => result.data)
.catch((error) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
return false
})
if (!bulk) setStore("deleting", undefined)
if (!removed) return
tabs.store.forEach((tab) => {
if (tab.type !== "draft" || tab.server !== context.server) return
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
if (!directoryMatches && !worktreeMatches) return
tabs.updateDraft(tab.draftID, {
directory: directoryMatches ? workspace.project.worktree : tab.directory,
worktree: undefined,
})
})
clearWorkspaceTerminals(
workspace.directory,
preflight.sessions.map((session) => session.id),
platform,
context.sdk.scope,
)
context.sync.set(
"project",
produce((draft) => {
const project = draft.find((item) => item.id === workspace.project.id)
if (!project) return
project.sandboxes = (project.sandboxes ?? []).filter(
(directory) => pathKey(directory) !== pathKey(workspace.directory),
)
}),
)
}
let inspectionID = 0
const releaseConfirmation = () => {
if (store.transaction === "confirm") setStore("transaction", undefined)
}
let transactionID = 0
const transact = async (task: () => Promise<void>) => {
await runWorkspaceDeleteTransaction({
token: ++transactionID,
set: (update) => setStore("transaction", update),
task: async () => {
try {
await task()
} finally {
setStore({ deleting: undefined, deletingAll: false })
}
},
})
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
const context = captureDeleteContext()
const current = ++inspectionID
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessage={inspectionMessage}
onDelete={() => transact(() => remove(workspace, false, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
setStore("deletingAll", true)
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, true, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.t(
filtered().length === 1 ? "settings.workspaces.count.one" : "settings.workspaces.count.other",
{
count: filtered().length,
},
)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<SelectV2
appearance="inline"
options={projectOptions()}
current={projectOptions().find((option) => option.id === selectedProject())}
value={(option) => option.id}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && setStore("project", option.id)}
/>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
{workspace.directory}
</span>
</TooltipV2>
<Show when={lastActive(workspace)}>
{(value) => <span class="settings-v2-workspaces-active">{value()}</span>}
</Show>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session, index) => (
<span class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={index() === 0}>
<span class="settings-v2-workspaces-session-active">
{language.t("settings.workspaces.lastActiveSession")}
</span>
</Show>
</span>
)}
</For>
</div>
</Show>
</div>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", { name: getFilename(workspace.directory) })}
disabled={!!store.transaction || store.deleting === pathKey(workspace.directory)}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
inspectionMessage: (result: WorkspaceDeleteInspection) => string
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const status = useQuery(() => ({
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
queryFn: props.inspect,
staleTime: 0,
}))
const description = () => {
if (status.isPending) return language.t("workspace.status.checking")
if (status.isError) return language.t("workspace.status.error")
return props.inspectionMessage(status.data?.result ?? "unknown")
}
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<br />
{description()}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={
status.isPending ||
status.isError ||
(status.data?.result !== "safe" && status.data?.result !== "dirty")
}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
-16
View File
@@ -353,22 +353,6 @@ 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,6 +152,12 @@ 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))
@@ -518,6 +524,17 @@ 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,13 +263,10 @@ describe("createChildStoreManager", () => {
manager.child("/project")
expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().enabled).toBe(true)
expect(queries[4]?.().enabled).toBe(false)
expect(queries[4]?.().enabled).toBe(true)
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,7 +47,6 @@ 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
@@ -123,7 +122,6 @@ export function createChildStoreManager(input: {
mcpToggles.delete(key)
activeDirectories.delete(key)
activationToggles.delete(key)
providerToggles.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
@@ -189,7 +187,6 @@ 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() }))
@@ -197,7 +194,7 @@ export function createChildStoreManager(input: {
const lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key),
enabled: providerEnabled(),
enabled: instanceQueriesEnabled(),
}))
const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key),
@@ -209,7 +206,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta,
icon: initialIcon,
get provider_ready() {
return providerEnabled() && !providerQuery.isLoading
return instanceQueriesEnabled() && !providerQuery.isLoading
},
get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -266,7 +263,6 @@ 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
@@ -333,12 +329,6 @@ 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,
@@ -397,7 +387,6 @@ export function createChildStoreManager(input: {
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp,
enableProviders,
disposeDirectory,
runEviction,
vcsCache,
@@ -208,57 +208,6 @@ describe("applyDirectoryEvent", () => {
expect(store.sessionTotal).toBe(2)
})
test("moves a root between loaded directory indexes exactly once", () => {
const info = { ...rootSession({ id: "session" }), directory: "/destination" }
const [source, setSource] = createStore(
baseState({ session: [{ ...info, directory: "/source" }], sessionTotal: 1 }),
)
const [destination, setDestination] = createStore(baseState({ session: [], sessionTotal: 0 }))
const created = { type: "session.created", properties: { info } }
const moved = {
type: "session.moved",
properties: { sessionID: info.id, location: { directory: info.directory } },
}
applyDirectoryEvent({
event: created,
store: destination,
setStore: setDestination,
push() {},
directory: "/destination",
loadLsp() {},
})
applyDirectoryEvent({
event: moved,
store: source,
setStore: setSource,
push() {},
directory: "/source",
loadLsp() {},
})
applyDirectoryEvent({
event: created,
store: destination,
setStore: setDestination,
push() {},
directory: "/destination",
loadLsp() {},
})
applyDirectoryEvent({
event: moved,
store: source,
setStore: setSource,
push() {},
directory: "/source",
loadLsp() {},
})
expect(source.session).toEqual([])
expect(source.sessionTotal).toBe(0)
expect(destination.session.map((session) => session.id)).toEqual([info.id])
expect(destination.sessionTotal).toBe(1)
})
test("cleans session caches when archived", () => {
const message = userMessage("msg_1", "ses_1")
const [store, setStore] = createStore(
@@ -22,7 +22,6 @@ 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: (
@@ -32,30 +31,7 @@ export async function loadHomeSessionIndex(
eventSequence = 0,
signal?: AbortSignal,
) {
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[] = []
const data: SessionV2Info[] = []
let cursor: string | undefined
for (;;) {
@@ -70,7 +46,7 @@ async function loadHomeSessionPages<T>(
const page = response.data!
data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next)
return { sessions: project(data), eventSequence }
return { sessions: parseHomeSessionIndex(data), eventSequence }
cursor = page.cursor.next
}
}
+7 -12
View File
@@ -8,7 +8,6 @@ import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import type { ServerScope } from "@/utils/server-scope"
import {
createPromptReady,
createPromptSession,
@@ -105,13 +104,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
const current = settings.general.newLayoutDesigns()
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
: undefined
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
const key = scopeKey(scope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -121,7 +118,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
value: createPromptSession(serverSDK().scope, scope),
dispose,
}),
owner,
@@ -133,8 +130,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
scope ? load(scope, target) : session()
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) =>
@@ -150,8 +146,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return {
ready,
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
pick(scope, target).capture(),
capture: (scope?: PromptScope) => pick(scope).capture(),
current: withSuspense(() => session().current()),
cursor: withSuspense(() => session().cursor()),
dirty: withSuspense(() => session().dirty()),
+1 -120
View File
@@ -1,126 +1,7 @@
import { describe, expect, test } from "bun:test"
import {
adaptServerEvent,
adaptWorktreeCompatibilityEvent,
applyWorkspaceOperationEvent,
applyWorktreeEvent,
coalesceServerEvents,
enqueueServerEvent,
resumeStreamAfterPageShow,
} from "./server-sdk"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client"
import { ServerScope } from "@/utils/server-scope"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
describe("applyWorktreeEvent", () => {
test("adapts global readiness with the created worktree directory", () => {
const directory = "/repo/worktree-compatible"
const event = adaptWorktreeCompatibilityEvent({
directory,
payload: { id: "ready", type: "worktree.ready", properties: { name: "compatible" } } as Event,
})
if (!event) throw new Error("expected worktree event")
Worktree.pending(ServerScope.local, directory)
applyWorktreeEvent(ServerScope.local, event, "failed")
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
})
test("ignores unrelated global compatibility events", () => {
expect(
adaptWorktreeCompatibilityEvent({
directory: "/repo",
payload: {
id: "status",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
} as Event,
}),
).toBeUndefined()
})
test("resolves readiness in the server scope that received the event", () => {
const directory = "/repo/worktree-ready"
Worktree.pending(ServerScope.local, directory)
expect(
applyWorktreeEvent(
ServerScope.local,
{ directory, payload: { id: "ready", type: "worktree.ready", properties: { name: "ready" } } as Event },
"failed",
),
).toBe(true)
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
})
test("preserves the server failure message", () => {
const directory = "/repo/worktree-failed"
Worktree.pending(ServerScope.local, directory)
applyWorktreeEvent(
ServerScope.local,
{
directory,
payload: {
id: "failed",
type: "worktree.failed",
properties: { name: "failed", message: "bootstrap failed" },
} as Event,
},
"fallback",
)
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" })
})
test("does not let duplicate readiness settle placement after creation", () => {
const directory = "/repo/worktree-placement"
Worktree.ready(ServerScope.local, directory)
WorkspaceOperation.start(ServerScope.local, "session-worktree-placement", "create", directory)
applyWorktreeEvent(
ServerScope.local,
{ directory, payload: { id: "ready-again", type: "worktree.ready", properties: { name: "ready" } } as Event },
"failed",
)
expect(WorkspaceOperation.get(ServerScope.local, "session-worktree-placement")?.status).toBe("pending")
})
})
test("moved events complete the matching workspace operation", () => {
WorkspaceOperation.start(ServerScope.local, "session-move", "move", "/workspace")
applyWorkspaceOperationEvent(ServerScope.local, {
directory: "/workspace",
payload: {
id: "moved",
type: "session.next.moved",
properties: {
timestamp: Date.now(),
sessionID: "session-move",
location: { directory: "/workspace" },
},
} as Event,
})
expect(WorkspaceOperation.get(ServerScope.local, "session-move")?.status).toBe("complete")
})
test("current moved events complete the matching workspace operation", () => {
WorkspaceOperation.start(ServerScope.local, "session-current-move", "move", "/workspace")
applyWorkspaceOperationEvent(ServerScope.local, {
directory: "/workspace",
payload: adaptServerEvent({
id: "moved-current",
created: Date.now(),
type: "session.moved",
data: {
sessionID: "session-current-move",
location: { directory: "/workspace" },
},
} as OpenCodeEvent),
})
expect(WorkspaceOperation.get(ServerScope.local, "session-current-move")?.status).toBe("complete")
})
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
+3 -104
View File
@@ -13,10 +13,6 @@ 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 { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
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"
@@ -60,11 +56,6 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
}
export function adaptWorktreeCompatibilityEvent(event: { directory?: string; payload: Event }) {
if (event.payload.type !== "worktree.ready" && event.payload.type !== "worktree.failed") return
return { directory: event.directory ?? "global", payload: event.payload }
}
const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") {
@@ -147,34 +138,6 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output
}
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
if (event.payload.type === "worktree.ready") {
const pending = Worktree.get(scope, event.directory)?.status === "pending"
Worktree.ready(scope, event.directory)
if (pending) WorkspaceOperation.completeCreate(scope, event.directory)
return true
}
if (event.payload.type !== "worktree.failed") return false
const message = event.payload.properties.message ?? fallback
Worktree.failed(scope, event.directory, message)
WorkspaceOperation.failCreate(scope, event.directory, message)
return true
}
export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) {
if (event.payload.current?.type === "session.moved") {
WorkspaceOperation.complete(
scope,
event.payload.current.data.sessionID,
event.payload.current.data.location.directory,
)
return true
}
if (event.payload.type !== "session.next.moved") return false
WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory)
return true
}
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if (
event?.type === "session.text.delta" ||
@@ -223,7 +186,6 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform()
const language = useLanguage()
const abort = new AbortController()
const eventFetch = (() => {
@@ -276,11 +238,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => {
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
applyWorkspaceOperationEvent(scope, event)
emitter.emit(event.directory, event.payload)
})
output.forEach((event) => emitter.emit(event.directory, event.payload))
})
buffer.length = 0
@@ -293,57 +251,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
let streamErrorLogged = false
let worktreeStreamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined
let worktreeAttempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const consumeWorktreeEvents = async (active: number) => {
// Current worktree lifecycle events are still emitted only on the global compatibility stream.
while (!abort.signal.aborted && started && generation === active) {
const controller = new AbortController()
worktreeAttempt = controller
const onAbort = () => controller.abort()
abort.signal.addEventListener("abort", onAbort)
try {
const events = (await eventSdk.global.event({ signal: controller.signal })).stream
let yielded = Date.now()
for await (const event of events) {
const queued = adaptWorktreeCompatibilityEvent({
directory: event.directory,
payload: event.payload as Event,
})
if (queued) {
worktreeStreamErrorLogged = false
if (enqueueServerEvent(queue, queued)) schedule()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) {
worktreeStreamErrorLogged = true
console.error("[global-sdk] worktree event stream failed", {
url: server.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
if (worktreeAttempt === controller) worktreeAttempt = undefined
}
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
}
const start = () => {
if (started) return run
started = true
@@ -351,8 +264,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
const previous = run
const current = (async () => {
if (previous) await previous
const kind = await protocol
if (kind === "v2") void consumeWorktreeEvents(active)
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
@@ -361,6 +272,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
abort.signal.addEventListener("abort", onAbort)
try {
const kind = await protocol
const events =
kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream
@@ -408,7 +320,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false
generation++
attempt?.abort()
worktreeAttempt?.abort()
}
onMount(() => {
@@ -435,7 +346,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
throwOnError: true,
directory,
})
const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList })
const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return {
server,
@@ -521,19 +432,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory,
decodeVcsDiff,
decodeSessionList,
}),
createApi(next: string) {
return createCompatibleApi({
protocol: serverSDK.protocol,
current: serverSDK.currentApi,
legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }),
directory: next,
decodeVcsDiff,
decodeSessionList,
})
},
event: emitter,
get url() {
return serverSDK.url
@@ -223,47 +223,6 @@ describe("server session", () => {
expect(ctx.store.lineage.peek("child")).toEqual(result)
})
test("applies moved session locations without evicting cached state", () => {
const current = { ...session("child"), directory: "/repo/worktree", path: undefined }
const ctx = setup({ child: current })
ctx.store.remember(current)
ctx.store.apply({
type: "session.next.moved",
properties: {
sessionID: "child",
location: { directory: "/repo" },
subdirectory: "packages/app",
},
})
expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" })
})
test("does not reuse a hung signal-scoped placement refresh", async () => {
let calls = 0
const client = {
session: {
get: async () => {
calls++
if (calls === 1) return new Promise<never>(() => {})
return { data: { ...session("child"), directory: "/destination" } }
},
},
} as unknown as OpencodeClient
const store = createServerSession(client)
store.remember({ ...session("child"), directory: "/source" })
const first = new AbortController()
void store.resolve("child", { force: true, signal: first.signal })
first.abort()
const result = await store.resolve("child", { force: true, signal: new AbortController().signal })
expect(calls).toBe(2)
expect(result.directory).toBe("/destination")
expect(store.get("child")?.directory).toBe("/destination")
})
test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") })
@@ -384,17 +343,6 @@ describe("server session", () => {
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("indexes optimistic users for the current timeline projection", () => {
const ctx = setup({})
const message = userMessage("message-optimistic")
ctx.store.optimistic.add({ sessionID: "child", message, parts: [] })
expect(ctx.store.data.session_message.child.map((item) => item.id)).toEqual([message.id])
ctx.store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(ctx.store.data.session_message.child).toEqual([])
})
test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
+8 -58
View File
@@ -22,7 +22,6 @@ 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"]
@@ -30,16 +29,10 @@ 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 = 50
const historyMessagePageSize = 200
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) =>
@@ -190,11 +183,7 @@ 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">
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
}
type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
export function createServerSession(
client: OpencodeClient,
@@ -312,24 +301,22 @@ export function createServerSession(
return session
}
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
const resolve = (sessionID: string, options?: { force?: boolean }) => {
const cached = data.info[sessionID]
if (cached && !options?.force) return Promise.resolve(cached)
const pending = options?.signal ? undefined : requests.get(sessionID)
const pending = requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = sessionApi
? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo)
: client.session.get({ sessionID }, { signal: options?.signal }).then((result) => {
? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
: client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID)
return result.data
})
const resolved = request.then((result) => {
if (options?.signal?.aborted) return result
if (generations.get(sessionID) !== active) return result
return remember(result)
})
if (options?.signal) return resolved
requests.set(sessionID, resolved)
const cleanup = () => {
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
@@ -565,7 +552,6 @@ 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 {
@@ -580,21 +566,10 @@ export function createServerSession(
complete: response.data.length === 0,
}
}
const response = await (options?.retry ?? retry)(async () => {
const response = await (options?.retry ?? retry)(() => {
onAttempt?.()
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) }
return client.session.messages({ sessionID, limit, before })
})
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)),
@@ -1043,27 +1018,6 @@ export function createServerSession(
evict([sessionID])
return
}
case "session.next.moved": {
const props = event.properties as {
timestamp: number
sessionID: string
location: { directory: string; workspaceID?: string }
subdirectory?: string
}
const current = data.info[props.sessionID]
if (!current) {
void resolve(props.sessionID, { force: true }).catch(() => {})
return
}
remember({
...current,
directory: props.location.directory,
path: props.subdirectory,
workspaceID: props.location.workspaceID,
time: { ...current.time, updated: props.timestamp },
})
return
}
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
@@ -1387,7 +1341,6 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
indexLegacyMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]))
setData(
"part_text_accum_delta",
@@ -1421,9 +1374,6 @@ export function createServerSession(
)
return
}
setData("session_message", input.sessionID, (messages) =>
messages?.filter((message) => message.id !== input.messageID),
)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
},
+2 -104
View File
@@ -1,27 +1,18 @@
import { describe, expect, test } from "bun:test"
import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client"
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
import type {
McpListInput,
McpResourceCatalogInput,
SessionApi,
SessionInfo,
SessionListInput,
OpenCodeEvent,
} from "@opencode-ai/client/promise"
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import {
captureSessionMove,
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
shouldRefreshWorkspaceSessions,
} from "./server-sync"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import { adaptServerEvent } from "./server-sdk"
import type { ServerApi } from "@/utils/server"
type McpApi = ServerApi["mcp"]
@@ -111,87 +102,6 @@ describe("active session query", () => {
})
})
describe("session move normalization", () => {
test("captures and applies current moves from the source placement", () => {
const session = createServerSession({} as OpencodeClient)
session.remember(sessionAt("/source"))
const current = {
id: "event-current-move",
created: 10,
type: "session.moved",
data: { sessionID: "session", location: { directory: "/destination" } },
} as OpenCodeEvent
const event = adaptServerEvent(current)
expect(captureSessionMove(event, session.get)).toEqual({
sessionID: "session",
from: "/source",
refresh: "session.next.moved",
})
session.applyV2(current)
session.apply(event)
expect(session.get("session")?.directory).toBe("/destination")
})
test("captures and applies V1 moves from the source placement", () => {
const session = createServerSession({} as OpencodeClient)
session.remember(sessionAt("/source"))
const event = {
type: "session.next.moved",
properties: {
timestamp: 10,
sessionID: "session",
location: { directory: "/destination" },
subdirectory: "packages/app",
},
} as Event
expect(captureSessionMove(event, session.get)).toEqual({
sessionID: "session",
from: "/source",
refresh: "session.next.moved",
})
session.apply(event)
expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" })
})
test("refreshes workspace inventory for current and V1 lifecycle events", () => {
const current = adaptServerEvent({
id: "event-current-move-refresh",
created: 10,
type: "session.moved",
data: { sessionID: "session", location: { directory: "/destination" } },
} as OpenCodeEvent)
const legacy = {
type: "session.next.moved",
properties: { timestamp: 10, sessionID: "session", location: { directory: "/destination" } },
} as Event
expect(shouldRefreshWorkspaceSessions(current)).toBe(true)
expect(
shouldRefreshWorkspaceSessions(
adaptServerEvent({
id: "event-current-rename-refresh",
created: 10,
type: "session.renamed",
data: { sessionID: "session", title: "Renamed" },
} as OpenCodeEvent),
),
).toBe(true)
expect(shouldRefreshWorkspaceSessions(legacy)).toBe(true)
expect(
shouldRefreshWorkspaceSessions({ type: "session.created", properties: { info: sessionAt("/source") } } as Event),
).toBe(true)
expect(
shouldRefreshWorkspaceSessions({ type: "session.updated", properties: { info: sessionAt("/source") } } as Event),
).toBe(true)
expect(
shouldRefreshWorkspaceSessions({ type: "session.deleted", properties: { info: sessionAt("/source") } } as Event),
).toBe(true)
expect(shouldRefreshWorkspaceSessions({ type: "server.connected", properties: {} } as Event)).toBe(false)
})
})
describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => {
const now = 5_000
@@ -264,18 +174,6 @@ function sessionInfo(id: string) {
} as SessionInfo
}
function sessionAt(directory: string): Session {
return {
id: "session",
slug: "session",
projectID: "project",
directory,
title: "Session",
version: "",
time: { created: 1, updated: 1 },
}
}
describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+27 -125
View File
@@ -8,11 +8,11 @@ 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, createSignal, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { type Accessor, batch, createMemo, 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"
import { ServerSDK, type ServerEvent } from "./server-sdk"
import { ServerSDK } from "./server-sdk"
import {
bootstrapDirectory,
bootstrapGlobal,
@@ -46,32 +46,6 @@ import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) {
const sessionID =
event.current?.type === "session.moved"
? event.current.data.sessionID
: event.type === "session.next.moved"
? event.properties.sessionID
: undefined
if (!sessionID) return
return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const }
}
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type: string = event.type
const current: string | undefined = event.current?.type
return (
type === "session.created" ||
type === "session.updated" ||
type === "session.deleted" ||
type === "session.next.moved" ||
current === "session.moved" ||
current === "session.renamed" ||
current === "session.archived" ||
current === "session.forked"
)
}
import { persisted } from "@/utils/persist"
import type { ServerApi } from "@/utils/server"
import type {
@@ -85,7 +59,6 @@ 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
@@ -253,7 +226,6 @@ 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,
@@ -263,40 +235,31 @@ 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(), enabled: backgroundEnabled() },
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
],
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
}))
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)) {
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)) {
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 active
},
}),
enabled: backgroundEnabled(),
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
},
}),
)
@@ -336,36 +299,10 @@ 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[])) => {
@@ -591,55 +528,19 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
})
}
const reindexSession = (sessionID: string, from?: string) => {
const next = session.get(sessionID)
if (!next) return
indexSession(next)
if (!from) return
const source = children.children[directoryKey(from)]
if (!source) return
applyDirectoryEvent({
event: {
type: "session.moved",
properties: {
sessionID,
projectID: next.projectID,
location: { directory: next.directory, workspaceID: next.workspaceID },
subpath: next.path,
},
},
directory: from,
store: source[0],
setStore: source[1],
push: queue.push,
retainedLimit: sessionMeta.get(directoryKey(from))?.limit,
sessionContent: false,
permission: session.data.permission,
loadLsp() {},
})
}
const unsub = serverSDK.event.listen((e) => {
const directory = e.name
const key = directoryKey(directory)
const event = e.details
const eventType: string = event.type
const recent = bootingRoot || Date.now() - bootedAt < 1500
const moved = captureSessionMove(event, session.get)
if (event.current) session.applyV2(event.current)
session.apply(event)
if (moved) reindexSession(moved.sessionID, moved.from)
if (shouldRefreshWorkspaceSessions(event)) {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
})
}
if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event)
}
homeSessions.refresh(moved?.refresh ?? event.type)
homeSessions.refresh(event.type)
if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") {
@@ -671,6 +572,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return
}
if (event.current?.type === "session.moved") {
const info = session.get(event.current.data.sessionID)
if (info) indexSession(info)
}
if (event.current?.type === "session.forked")
void session
.resolve(event.current.data.sessionID, { force: true })
@@ -777,15 +682,12 @@ 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,
project: projectApi,
session,
reindexSession,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {
@@ -1,64 +0,0 @@
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"])
})
@@ -1,94 +0,0 @@
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
}
@@ -1,56 +0,0 @@
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(new URL("./session-message-decoder.worker.ts", import.meta.url), { 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
}
@@ -1,25 +0,0 @@
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 {}
-35
View File
@@ -3,10 +3,6 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export interface NotificationSettings {
agent: boolean
@@ -54,10 +50,6 @@ export interface Settings {
permissions: {
autoApprove: boolean
}
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings
sounds: SoundSettings
}
@@ -214,10 +206,6 @@ const defaultSettings: Settings = {
permissions: {
autoApprove: false,
},
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: {
agent: true,
permissions: true,
@@ -511,29 +499,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("permissions", "autoApprove", value)
},
},
workspaces: {
defaultDestination: withFallback(
() => store.workspaces?.defaultDestination,
defaultSettings.workspaces.defaultDestination,
),
setDefaultDestination(value: WorkspaceDefaultDestination) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
defaultDestination: value,
}))
},
lastUsed(scope: ServerScope, projectID: string) {
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
},
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
}))
},
},
notifications: {
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) {
@@ -1,43 +0,0 @@
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")
})
})
-28
View File
@@ -1,28 +0,0 @@
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 }
}
}
-21
View File
@@ -13,7 +13,6 @@ 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"
@@ -75,7 +74,6 @@ 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
@@ -150,21 +148,10 @@ 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
@@ -190,11 +177,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
const actions = {
active() {
if (location.pathname === "/") return
const key = recentKey()
return store.find((tab) => tabKey(tab) === key)
},
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))
@@ -375,11 +357,8 @@ 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())
-23
View File
@@ -2,29 +2,6 @@ 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: "Cmd+Option+Left" },
},
{
type: "item",
labelKey: "desktop.menu.nextTab",
command: "tab.next",
accelerator: { macos: "Cmd+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",
-3
View File
@@ -237,9 +237,6 @@ 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: "Cmd+Option+Left" } },
{ type: "item", labelKey: "desktop.menu.nextTab", command: "tab.next", accelerator: { macos: "Cmd+Option+Right" } },
{ type: "separator" },
{
type: "item",
labelKey: "desktop.menu.previousSession",
-6
View File
@@ -21,12 +21,6 @@ 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
-33
View File
@@ -1033,39 +1033,6 @@ export const dict = {
"session.delete.confirm": 'حذف الجلسة "{{name}}"؟',
"session.delete.button": "حذف الجلسة",
"workspace.new": "مساحة عمل جديدة",
"common.viewAll": "عرض الكل",
"session.new.workspace.local.tooltip": "استخدام نسخة العمل الحالية",
"session.new.workspace.new.tooltip": "إنشاء نسخة عمل معزولة",
"session.new.workspace.fromBranch": "من {{branch}}",
"session.new.workspace.trigger.tooltip": "تحديد مكان تشغيل الجلسة",
"session.new.workspace.search.placeholder": "البحث في مساحات العمل",
"settings.tab.workspaces": "مساحات العمل",
"settings.workspaces.filter.all": "جميع المشاريع",
"settings.workspaces.empty": "لا توجد مساحات عمل",
"settings.workspaces.count.one": "{{count}} مساحة عمل",
"settings.workspaces.count.other": "{{count}} مساحات عمل",
"settings.workspaces.sessions.one": "{{count}} جلسة في {{project}}",
"settings.workspaces.sessions.other": "{{count}} جلسات في {{project}}",
"settings.workspaces.lastActiveSession": "آخر جلسة نشطة",
"settings.workspaces.deleteAll": "حذف جميع مساحات العمل",
"settings.workspaces.deleteAll.confirm": "حذف جميع مساحات العمل البالغ عددها {{count}}؟",
"settings.workspaces.delete.warning":
"سيتم حذف دليل مساحة العمل والفرع نهائيًا. لن تتم المتابعة إلا إذا كانت نظيفة وغير نشطة ولا تحتوي على جلسات مرتبطة.",
"settings.workspaces.deleteAll.warning":
"سيتم حذف مساحات العمل المحددة وعددها {{count}} في {{project}} نهائيًا فقط إذا كانت كل واحدة نظيفة وغير نشطة ولا تحتوي على جلسات مرتبطة.",
"settings.workspaces.delete.blocked.active": "لا يمكن حذف مساحة العمل النشطة.",
"settings.workspaces.delete.blocked.linked": "تحتوي مساحة العمل هذه على جلسات مرتبطة ولا يمكن حذفها.",
"settings.workspaces.default.title": "البيئة الافتراضية",
"settings.workspaces.default.description": "اختر مكان بدء الجلسات الجديدة",
"settings.workspaces.default.lastUsed": "آخر بيئة مستخدمة لكل مشروع",
"settings.workspaces.default.local": "دليل محلي",
"settings.workspaces.default.new": "مساحة عمل جديدة",
"workspace.move.title": "نقل إلى مساحة العمل",
"workspace.move.failed": "تعذّر نقل الجلسة",
"workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل",
"workspace.lifecycle.created": "تم إنشاء مساحة العمل",
"workspace.lifecycle.moving": "جارٍ النقل إلى مساحة العمل",
"workspace.lifecycle.set": "تم تعيين مساحة العمل",
"workspace.type.local": "محلي",
"workspace.type.sandbox": "بيئة معزولة",
"workspace.create.failed.title": "فشل إنشاء مساحة العمل",
-34
View File
@@ -1041,40 +1041,6 @@ export const dict = {
"session.delete.confirm": 'Excluir sessão "{{name}}"?',
"session.delete.button": "Excluir sessão",
"workspace.new": "Novo espaço de trabalho",
"common.viewAll": "Ver todos",
"session.new.workspace.local.tooltip": "Usar o checkout atual",
"session.new.workspace.new.tooltip": "Criar checkout isolado",
"session.new.workspace.fromBranch": "de {{branch}}",
"session.new.workspace.trigger.tooltip": "Selecionar onde executar a sessão",
"session.new.workspace.search.placeholder": "Pesquisar espaços de trabalho",
"settings.tab.workspaces": "Espaços de trabalho",
"settings.workspaces.filter.all": "Todos os projetos",
"settings.workspaces.empty": "Nenhum espaço de trabalho",
"settings.workspaces.count.one": "{{count}} espaço de trabalho",
"settings.workspaces.count.other": "{{count}} espaços de trabalho",
"settings.workspaces.sessions.one": "{{count}} sessão em {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessões em {{project}}",
"settings.workspaces.lastActiveSession": "Última sessão ativa",
"settings.workspaces.deleteAll": "Excluir todos os espaços de trabalho",
"settings.workspaces.deleteAll.confirm": "Excluir todos os {{count}} espaços de trabalho?",
"settings.workspaces.delete.warning":
"O diretório e a ramificação do espaço de trabalho serão removidos permanentemente. A exclusão só prossegue se ele estiver limpo, inativo e sem sessões vinculadas.",
"settings.workspaces.deleteAll.warning":
"Os {{count}} espaços de trabalho selecionados em {{project}} serão removidos permanentemente somente se cada um estiver limpo, inativo e sem sessões vinculadas.",
"settings.workspaces.delete.blocked.active": "O espaço de trabalho ativo não pode ser excluído.",
"settings.workspaces.delete.blocked.linked":
"Este espaço de trabalho tem sessões vinculadas e não pode ser excluído.",
"settings.workspaces.default.title": "Ambiente padrão",
"settings.workspaces.default.description": "Escolha onde as novas sessões começam",
"settings.workspaces.default.lastUsed": "Último usado por projeto",
"settings.workspaces.default.local": "Diretório local",
"settings.workspaces.default.new": "Novo espaço de trabalho",
"workspace.move.title": "Mover para o espaço de trabalho",
"workspace.move.failed": "Não foi possível mover a sessão",
"workspace.lifecycle.creating": "Criando espaço de trabalho",
"workspace.lifecycle.created": "Espaço de trabalho criado",
"workspace.lifecycle.moving": "Movendo para o espaço de trabalho",
"workspace.lifecycle.set": "Espaço de trabalho definido",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Falha ao criar espaço de trabalho",
-33
View File
@@ -1117,39 +1117,6 @@ export const dict = {
"session.delete.button": "Izbriši sesiju",
"workspace.new": "Novi radni prostor",
"common.viewAll": "Prikaži sve",
"session.new.workspace.local.tooltip": "Koristi trenutni checkout",
"session.new.workspace.new.tooltip": "Kreiraj izolovani checkout",
"session.new.workspace.fromBranch": "iz {{branch}}",
"session.new.workspace.trigger.tooltip": "Odaberi gdje pokrenuti sesiju",
"session.new.workspace.search.placeholder": "Pretraži radne prostore",
"settings.tab.workspaces": "Radni prostori",
"settings.workspaces.filter.all": "Svi projekti",
"settings.workspaces.empty": "Nema radnih prostora",
"settings.workspaces.count.one": "{{count}} radni prostor",
"settings.workspaces.count.other": "{{count}} radnih prostora",
"settings.workspaces.sessions.one": "{{count}} sesija u projektu {{project}}",
"settings.workspaces.sessions.other": "{{count}} sesija u projektu {{project}}",
"settings.workspaces.lastActiveSession": "Posljednja aktivna sesija",
"settings.workspaces.deleteAll": "Izbriši sve radne prostore",
"settings.workspaces.deleteAll.confirm": "Izbrisati svih {{count}} radnih prostora?",
"settings.workspaces.delete.warning":
"Direktorij radnog prostora i grana bit će trajno izbrisani. Brisanje se nastavlja samo ako je radni prostor čist, neaktivan i nema povezanih sesija.",
"settings.workspaces.deleteAll.warning":
"Odabranih {{count}} radnih prostora u {{project}} bit će trajno izbrisano samo ako je svaki čist, neaktivan i nema povezanih sesija.",
"settings.workspaces.delete.blocked.active": "Aktivni radni prostor nije moguće izbrisati.",
"settings.workspaces.delete.blocked.linked": "Ovaj radni prostor ima povezane sesije i nije ga moguće izbrisati.",
"settings.workspaces.default.title": "Zadano okruženje",
"settings.workspaces.default.description": "Odaberite gdje počinju nove sesije",
"settings.workspaces.default.lastUsed": "Posljednje korišteno po projektu",
"settings.workspaces.default.local": "Lokalni direktorij",
"settings.workspaces.default.new": "Novi radni prostor",
"workspace.move.title": "Premjesti u radni prostor",
"workspace.move.failed": "Premještanje sesije nije uspjelo",
"workspace.lifecycle.creating": "Kreiranje radnog prostora",
"workspace.lifecycle.created": "Radni prostor je kreiran",
"workspace.lifecycle.moving": "Premještanje u radni prostor",
"workspace.lifecycle.set": "Radni prostor je postavljen",
"workspace.type.local": "lokalno",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",
-33
View File
@@ -991,39 +991,6 @@ export const dict = {
"session.delete.button": "Slet session",
"workspace.new": "Nyt arbejdsområde",
"common.viewAll": "Vis alle",
"session.new.workspace.local.tooltip": "Brug det aktuelle checkout",
"session.new.workspace.new.tooltip": "Opret et isoleret checkout",
"session.new.workspace.fromBranch": "fra {{branch}}",
"session.new.workspace.trigger.tooltip": "Vælg, hvor sessionen skal køre",
"session.new.workspace.search.placeholder": "Søg i arbejdsområder",
"settings.tab.workspaces": "Arbejdsområder",
"settings.workspaces.filter.all": "Alle projekter",
"settings.workspaces.empty": "Ingen arbejdsområder",
"settings.workspaces.count.one": "{{count}} arbejdsområde",
"settings.workspaces.count.other": "{{count}} arbejdsområder",
"settings.workspaces.sessions.one": "{{count}} session i {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessioner i {{project}}",
"settings.workspaces.lastActiveSession": "Senest aktive session",
"settings.workspaces.deleteAll": "Slet alle arbejdsområder",
"settings.workspaces.deleteAll.confirm": "Slet alle {{count}} arbejdsområder?",
"settings.workspaces.delete.warning":
"Arbejdsområdets mappe og gren fjernes permanent. Sletning fortsætter kun, hvis det er rent, inaktivt og ikke har tilknyttede sessioner.",
"settings.workspaces.deleteAll.warning":
"De {{count}} valgte arbejdsområder i {{project}} fjernes permanent, men kun hvis hvert område er rent, inaktivt og ikke har tilknyttede sessioner.",
"settings.workspaces.delete.blocked.active": "Det aktive arbejdsområde kan ikke slettes.",
"settings.workspaces.delete.blocked.linked": "Dette arbejdsområde har tilknyttede sessioner og kan ikke slettes.",
"settings.workspaces.default.title": "Standardmiljø",
"settings.workspaces.default.description": "Vælg, hvor nye sessioner starter",
"settings.workspaces.default.lastUsed": "Senest brugt pr. projekt",
"settings.workspaces.default.local": "Lokal mappe",
"settings.workspaces.default.new": "Nyt arbejdsområde",
"workspace.move.title": "Flyt til arbejdsområde",
"workspace.move.failed": "Sessionen kunne ikke flyttes",
"workspace.lifecycle.creating": "Opretter arbejdsområde",
"workspace.lifecycle.created": "Arbejdsområde oprettet",
"workspace.lifecycle.moving": "Flytter til arbejdsområde",
"workspace.lifecycle.set": "Arbejdsområde angivet",
"workspace.type.local": "lokal",
"workspace.type.sandbox": "sandkasse",
"workspace.create.failed.title": "Kunne ikke oprette arbejdsområde",
-34
View File
@@ -934,40 +934,6 @@ export const dict = {
"session.delete.confirm": 'Sitzung "{{name}}" löschen?',
"session.delete.button": "Sitzung löschen",
"workspace.new": "Neuer Arbeitsbereich",
"common.viewAll": "Alle anzeigen",
"session.new.workspace.local.tooltip": "Aktuellen Checkout verwenden",
"session.new.workspace.new.tooltip": "Isolierten Checkout erstellen",
"session.new.workspace.fromBranch": "aus {{branch}}",
"session.new.workspace.trigger.tooltip": "Auswählen, wo die Sitzung ausgeführt werden soll",
"session.new.workspace.search.placeholder": "Arbeitsbereiche durchsuchen",
"settings.tab.workspaces": "Arbeitsbereiche",
"settings.workspaces.filter.all": "Alle Projekte",
"settings.workspaces.empty": "Keine Arbeitsbereiche",
"settings.workspaces.count.one": "{{count}} Arbeitsbereich",
"settings.workspaces.count.other": "{{count}} Arbeitsbereiche",
"settings.workspaces.sessions.one": "{{count}} Sitzung in {{project}}",
"settings.workspaces.sessions.other": "{{count}} Sitzungen in {{project}}",
"settings.workspaces.lastActiveSession": "Zuletzt aktive Sitzung",
"settings.workspaces.deleteAll": "Alle Arbeitsbereiche löschen",
"settings.workspaces.deleteAll.confirm": "Alle {{count}} Arbeitsbereiche löschen?",
"settings.workspaces.delete.warning":
"Verzeichnis und Branch des Arbeitsbereichs werden dauerhaft entfernt. Die Löschung erfolgt nur, wenn er sauber und inaktiv ist und keine verknüpften Sitzungen hat.",
"settings.workspaces.deleteAll.warning":
"Die {{count}} ausgewählten Arbeitsbereiche in {{project}} werden nur dauerhaft entfernt, wenn jeder sauber und inaktiv ist und keine verknüpften Sitzungen hat.",
"settings.workspaces.delete.blocked.active": "Der aktive Arbeitsbereich kann nicht gelöscht werden.",
"settings.workspaces.delete.blocked.linked":
"Dieser Arbeitsbereich hat verknüpfte Sitzungen und kann nicht gelöscht werden.",
"settings.workspaces.default.title": "Standardumgebung",
"settings.workspaces.default.description": "Wählen Sie, wo neue Sitzungen starten",
"settings.workspaces.default.lastUsed": "Zuletzt pro Projekt verwendet",
"settings.workspaces.default.local": "Lokales Verzeichnis",
"settings.workspaces.default.new": "Neuer Arbeitsbereich",
"workspace.move.title": "In Arbeitsbereich verschieben",
"workspace.move.failed": "Sitzung konnte nicht verschoben werden",
"workspace.lifecycle.creating": "Arbeitsbereich wird erstellt",
"workspace.lifecycle.created": "Arbeitsbereich erstellt",
"workspace.lifecycle.moving": "Wird in den Arbeitsbereich verschoben",
"workspace.lifecycle.set": "Arbeitsbereich festgelegt",
"workspace.type.local": "lokal",
"workspace.type.sandbox": "Sandbox",
"workspace.create.failed.title": "Arbeitsbereich konnte nicht erstellt werden",
-2
View File
@@ -66,8 +66,6 @@ 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",
-36
View File
@@ -1113,42 +1113,6 @@ export const dict = {
"session.delete.button": "Delete session",
"workspace.new": "New workspace",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search workspaces",
"settings.tab.workspaces": "Workspaces",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No workspaces",
"settings.workspaces.count.one": "{{count}} workspace",
"settings.workspaces.count.other": "{{count}} workspaces",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all workspaces",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
"settings.workspaces.delete.warning":
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
"settings.workspaces.deleteAll.warning":
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
"settings.workspaces.default.local": "Local directory",
"settings.workspaces.default.new": "New workspace",
"workspace.move.title": "Move to workspace",
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating workspace",
"workspace.lifecycle.created": "Workspace created",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with workspaces",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.lifecycle.moving": "Moving to workspace",
"workspace.lifecycle.set": "Workspace set",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create workspace",
-34
View File
@@ -1125,40 +1125,6 @@ export const dict = {
"session.delete.button": "Eliminar sesión",
"workspace.new": "Nuevo espacio de trabajo",
"common.viewAll": "Ver todo",
"session.new.workspace.local.tooltip": "Usar el checkout actual",
"session.new.workspace.new.tooltip": "Crear un checkout aislado",
"session.new.workspace.fromBranch": "desde {{branch}}",
"session.new.workspace.trigger.tooltip": "Seleccionar dónde ejecutar la sesión",
"session.new.workspace.search.placeholder": "Buscar espacios de trabajo",
"settings.tab.workspaces": "Espacios de trabajo",
"settings.workspaces.filter.all": "Todos los proyectos",
"settings.workspaces.empty": "No hay espacios de trabajo",
"settings.workspaces.count.one": "{{count}} espacio de trabajo",
"settings.workspaces.count.other": "{{count}} espacios de trabajo",
"settings.workspaces.sessions.one": "{{count}} sesión en {{project}}",
"settings.workspaces.sessions.other": "{{count}} sesiones en {{project}}",
"settings.workspaces.lastActiveSession": "Última sesión activa",
"settings.workspaces.deleteAll": "Eliminar todos los espacios de trabajo",
"settings.workspaces.deleteAll.confirm": "¿Eliminar los {{count}} espacios de trabajo?",
"settings.workspaces.delete.warning":
"El directorio y la rama del espacio de trabajo se eliminarán permanentemente. La eliminación solo continuará si está limpio, inactivo y no tiene sesiones vinculadas.",
"settings.workspaces.deleteAll.warning":
"Los {{count}} espacios de trabajo seleccionados en {{project}} se eliminarán permanentemente solo si cada uno está limpio, inactivo y no tiene sesiones vinculadas.",
"settings.workspaces.delete.blocked.active": "El espacio de trabajo activo no se puede eliminar.",
"settings.workspaces.delete.blocked.linked":
"Este espacio de trabajo tiene sesiones vinculadas y no se puede eliminar.",
"settings.workspaces.default.title": "Entorno predeterminado",
"settings.workspaces.default.description": "Elige dónde se inician las nuevas sesiones",
"settings.workspaces.default.lastUsed": "Último usado en cada proyecto",
"settings.workspaces.default.local": "Directorio local",
"settings.workspaces.default.new": "Nuevo espacio de trabajo",
"workspace.move.title": "Mover al espacio de trabajo",
"workspace.move.failed": "No se pudo mover la sesión",
"workspace.lifecycle.creating": "Creando espacio de trabajo",
"workspace.lifecycle.created": "Espacio de trabajo creado",
"workspace.lifecycle.moving": "Moviendo al espacio de trabajo",
"workspace.lifecycle.set": "Espacio de trabajo establecido",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Fallo al crear espacio de trabajo",
-34
View File
@@ -1051,40 +1051,6 @@ export const dict = {
"session.delete.confirm": 'Supprimer la session "{{name}}" ?',
"session.delete.button": "Supprimer la session",
"workspace.new": "Nouvel espace de travail",
"common.viewAll": "Tout afficher",
"session.new.workspace.local.tooltip": "Utiliser le checkout actuel",
"session.new.workspace.new.tooltip": "Créer un checkout isolé",
"session.new.workspace.fromBranch": "depuis {{branch}}",
"session.new.workspace.trigger.tooltip": "Choisir où exécuter la session",
"session.new.workspace.search.placeholder": "Rechercher des espaces de travail",
"settings.tab.workspaces": "Espaces de travail",
"settings.workspaces.filter.all": "Tous les projets",
"settings.workspaces.empty": "Aucun espace de travail",
"settings.workspaces.count.one": "{{count}} espace de travail",
"settings.workspaces.count.other": "{{count}} espaces de travail",
"settings.workspaces.sessions.one": "{{count}} session dans {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions dans {{project}}",
"settings.workspaces.lastActiveSession": "Dernière session active",
"settings.workspaces.deleteAll": "Supprimer tous les espaces de travail",
"settings.workspaces.deleteAll.confirm": "Supprimer les {{count}} espaces de travail ?",
"settings.workspaces.delete.warning":
"Le dossier et la branche de lespace de travail seront supprimés définitivement. La suppression ne continue que sil est propre, inactif et sans session liée.",
"settings.workspaces.deleteAll.warning":
"Les {{count}} espaces de travail sélectionnés dans {{project}} seront supprimés définitivement uniquement si chacun est propre, inactif et sans session liée.",
"settings.workspaces.delete.blocked.active": "Lespace de travail actif ne peut pas être supprimé.",
"settings.workspaces.delete.blocked.linked":
"Cet espace de travail contient des sessions liées et ne peut pas être supprimé.",
"settings.workspaces.default.title": "Environnement par défaut",
"settings.workspaces.default.description": "Choisissez où démarrent les nouvelles sessions",
"settings.workspaces.default.lastUsed": "Dernier utilisé par projet",
"settings.workspaces.default.local": "Dossier local",
"settings.workspaces.default.new": "Nouvel espace de travail",
"workspace.move.title": "Déplacer vers lespace de travail",
"workspace.move.failed": "Impossible de déplacer la session",
"workspace.lifecycle.creating": "Création de lespace de travail",
"workspace.lifecycle.created": "Espace de travail créé",
"workspace.lifecycle.moving": "Déplacement vers lespace de travail",
"workspace.lifecycle.set": "Espace de travail défini",
"workspace.type.local": "local",
"workspace.type.sandbox": "bac à sable",
"workspace.create.failed.title": "Échec de la création de l'espace de travail",
-33
View File
@@ -1023,39 +1023,6 @@ export const dict = {
"session.delete.confirm": 'セッション "{{name}}" を削除しますか?',
"session.delete.button": "セッションを削除",
"workspace.new": "新しいワークスペース",
"common.viewAll": "すべて表示",
"session.new.workspace.local.tooltip": "現在のチェックアウトを使用",
"session.new.workspace.new.tooltip": "隔離されたチェックアウトを作成",
"session.new.workspace.fromBranch": "{{branch}} から",
"session.new.workspace.trigger.tooltip": "セッションの実行先を選択します",
"session.new.workspace.search.placeholder": "ワークスペースを検索",
"settings.tab.workspaces": "ワークスペース",
"settings.workspaces.filter.all": "すべてのプロジェクト",
"settings.workspaces.empty": "ワークスペースなし",
"settings.workspaces.count.one": "{{count}}件のワークスペース",
"settings.workspaces.count.other": "{{count}}件のワークスペース",
"settings.workspaces.sessions.one": "{{project}}のセッション{{count}}件",
"settings.workspaces.sessions.other": "{{project}}のセッション{{count}}件",
"settings.workspaces.lastActiveSession": "最後にアクティブだったセッション",
"settings.workspaces.deleteAll": "すべてのワークスペースを削除",
"settings.workspaces.deleteAll.confirm": "{{count}}件のワークスペースをすべて削除しますか?",
"settings.workspaces.delete.warning":
"ワークスペースのディレクトリとブランチは完全に削除されます。クリーンで非アクティブかつリンクされたセッションがない場合にのみ削除されます。",
"settings.workspaces.deleteAll.warning":
"{{project}} で選択された{{count}}件のワークスペースは、それぞれがクリーンで非アクティブかつリンクされたセッションがない場合にのみ完全に削除されます。",
"settings.workspaces.delete.blocked.active": "アクティブなワークスペースは削除できません。",
"settings.workspaces.delete.blocked.linked": "このワークスペースにはリンクされたセッションがあるため削除できません。",
"settings.workspaces.default.title": "デフォルト環境",
"settings.workspaces.default.description": "新しいセッションの開始場所を選択します",
"settings.workspaces.default.lastUsed": "プロジェクトごとに前回使用した環境",
"settings.workspaces.default.local": "ローカルディレクトリ",
"settings.workspaces.default.new": "新しいワークスペース",
"workspace.move.title": "ワークスペースに移動",
"workspace.move.failed": "セッションを移動できませんでした",
"workspace.lifecycle.creating": "ワークスペースを作成中",
"workspace.lifecycle.created": "ワークスペースを作成しました",
"workspace.lifecycle.moving": "ワークスペースに移動中",
"workspace.lifecycle.set": "ワークスペースを設定しました",
"workspace.type.local": "ローカル",
"workspace.type.sandbox": "サンドボックス",
"workspace.create.failed.title": "ワークスペースの作成に失敗しました",
-33
View File
@@ -748,39 +748,6 @@ export const dict = {
"session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?',
"session.delete.button": "세션 삭제",
"workspace.new": "새 작업 공간",
"common.viewAll": "모두 보기",
"session.new.workspace.local.tooltip": "현재 체크아웃 사용",
"session.new.workspace.new.tooltip": "격리된 체크아웃 생성",
"session.new.workspace.fromBranch": "{{branch}}에서",
"session.new.workspace.trigger.tooltip": "세션을 실행할 위치를 선택하세요",
"session.new.workspace.search.placeholder": "작업 공간 검색",
"settings.tab.workspaces": "작업 공간",
"settings.workspaces.filter.all": "모든 프로젝트",
"settings.workspaces.empty": "작업 공간 없음",
"settings.workspaces.count.one": "작업 공간 {{count}}개",
"settings.workspaces.count.other": "작업 공간 {{count}}개",
"settings.workspaces.sessions.one": "{{project}}에 세션 {{count}}개",
"settings.workspaces.sessions.other": "{{project}}에 세션 {{count}}개",
"settings.workspaces.lastActiveSession": "마지막 활성 세션",
"settings.workspaces.deleteAll": "모든 작업 공간 삭제",
"settings.workspaces.deleteAll.confirm": "작업 공간 {{count}}개를 모두 삭제할까요?",
"settings.workspaces.delete.warning":
"작업 공간 디렉터리와 브랜치가 영구적으로 삭제됩니다. 깨끗하고 비활성 상태이며 연결된 세션이 없는 경우에만 삭제됩니다.",
"settings.workspaces.deleteAll.warning":
"{{project}}에서 선택한 작업 공간 {{count}}개는 각각 깨끗하고 비활성 상태이며 연결된 세션이 없는 경우에만 영구적으로 삭제됩니다.",
"settings.workspaces.delete.blocked.active": "활성 작업 공간은 삭제할 수 없습니다.",
"settings.workspaces.delete.blocked.linked": "이 작업 공간에는 연결된 세션이 있어 삭제할 수 없습니다.",
"settings.workspaces.default.title": "기본 환경",
"settings.workspaces.default.description": "새 세션을 시작할 위치를 선택하세요",
"settings.workspaces.default.lastUsed": "프로젝트별 마지막 사용 환경",
"settings.workspaces.default.local": "로컬 디렉터리",
"settings.workspaces.default.new": "새 작업 공간",
"workspace.move.title": "작업 공간으로 이동",
"workspace.move.failed": "세션을 이동하지 못했습니다",
"workspace.lifecycle.creating": "작업 공간 생성 중",
"workspace.lifecycle.created": "작업 공간을 만들었습니다",
"workspace.lifecycle.moving": "작업 공간으로 이동 중",
"workspace.lifecycle.set": "작업 공간을 설정했습니다",
"workspace.type.local": "로컬",
"workspace.type.sandbox": "샌드박스",
"workspace.create.failed.title": "작업 공간 생성 실패",
-33
View File
@@ -944,39 +944,6 @@ export const dict = {
"session.delete.button": "Slett sesjon",
"workspace.new": "Nytt arbeidsområde",
"common.viewAll": "Vis alle",
"session.new.workspace.local.tooltip": "Bruk gjeldende checkout",
"session.new.workspace.new.tooltip": "Opprett isolert checkout",
"session.new.workspace.fromBranch": "fra {{branch}}",
"session.new.workspace.trigger.tooltip": "Velg hvor sesjonen skal kjøres",
"session.new.workspace.search.placeholder": "Søk i arbeidsområder",
"settings.tab.workspaces": "Arbeidsområder",
"settings.workspaces.filter.all": "Alle prosjekter",
"settings.workspaces.empty": "Ingen arbeidsområder",
"settings.workspaces.count.one": "{{count}} arbeidsområde",
"settings.workspaces.count.other": "{{count}} arbeidsområder",
"settings.workspaces.sessions.one": "{{count}} økt i {{project}}",
"settings.workspaces.sessions.other": "{{count}} økter i {{project}}",
"settings.workspaces.lastActiveSession": "Sist aktive økt",
"settings.workspaces.deleteAll": "Slett alle arbeidsområder",
"settings.workspaces.deleteAll.confirm": "Slette alle {{count}} arbeidsområder?",
"settings.workspaces.delete.warning":
"Arbeidsområdets mappe og gren fjernes permanent. Sletting fortsetter bare hvis det er rent, inaktivt og ikke har tilknyttede økter.",
"settings.workspaces.deleteAll.warning":
"De {{count}} valgte arbeidsområdene i {{project}} fjernes permanent bare hvis hvert område er rent, inaktivt og ikke har tilknyttede økter.",
"settings.workspaces.delete.blocked.active": "Det aktive arbeidsområdet kan ikke slettes.",
"settings.workspaces.delete.blocked.linked": "Dette arbeidsområdet har tilknyttede økter og kan ikke slettes.",
"settings.workspaces.default.title": "Standardmiljø",
"settings.workspaces.default.description": "Velg hvor nye økter starter",
"settings.workspaces.default.lastUsed": "Sist brukt per prosjekt",
"settings.workspaces.default.local": "Lokal mappe",
"settings.workspaces.default.new": "Nytt arbeidsområde",
"workspace.move.title": "Flytt til arbeidsområde",
"workspace.move.failed": "Kunne ikke flytte økten",
"workspace.lifecycle.creating": "Oppretter arbeidsområde",
"workspace.lifecycle.created": "Arbeidsområdet er opprettet",
"workspace.lifecycle.moving": "Flytter til arbeidsområde",
"workspace.lifecycle.set": "Arbeidsområdet er angitt",
"workspace.type.local": "lokal",
"workspace.type.sandbox": "sandkasse",
"workspace.create.failed.title": "Kunne ikke opprette arbeidsområde",
-33
View File
@@ -1043,39 +1043,6 @@ export const dict = {
"session.delete.confirm": 'Usunąć sesję "{{name}}"?',
"session.delete.button": "Usuń sesję",
"workspace.new": "Nowa przestrzeń robocza",
"common.viewAll": "Pokaż wszystko",
"session.new.workspace.local.tooltip": "Użyj bieżącego checkoutu",
"session.new.workspace.new.tooltip": "Utwórz izolowany checkout",
"session.new.workspace.fromBranch": "z {{branch}}",
"session.new.workspace.trigger.tooltip": "Wybierz, gdzie uruchomić sesję",
"session.new.workspace.search.placeholder": "Szukaj przestrzeni roboczych",
"settings.tab.workspaces": "Obszary robocze",
"settings.workspaces.filter.all": "Wszystkie projekty",
"settings.workspaces.empty": "Brak obszarów roboczych",
"settings.workspaces.count.one": "{{count}} obszar roboczy",
"settings.workspaces.count.other": "{{count}} obszarów roboczych",
"settings.workspaces.sessions.one": "{{count}} sesja w {{project}}",
"settings.workspaces.sessions.other": "{{count}} sesji w {{project}}",
"settings.workspaces.lastActiveSession": "Ostatnia aktywna sesja",
"settings.workspaces.deleteAll": "Usuń wszystkie obszary robocze",
"settings.workspaces.deleteAll.confirm": "Usunąć wszystkie obszary robocze ({{count}})?",
"settings.workspaces.delete.warning":
"Katalog i gałąź obszaru roboczego zostaną trwale usunięte. Usuwanie nastąpi tylko wtedy, gdy obszar jest czysty, nieaktywny i nie ma powiązanych sesji.",
"settings.workspaces.deleteAll.warning":
"Wybrane obszary robocze ({{count}}) w {{project}} zostaną trwale usunięte tylko wtedy, gdy każdy jest czysty, nieaktywny i nie ma powiązanych sesji.",
"settings.workspaces.delete.blocked.active": "Nie można usunąć aktywnego obszaru roboczego.",
"settings.workspaces.delete.blocked.linked": "Ten obszar roboczy ma powiązane sesje i nie można go usunąć.",
"settings.workspaces.default.title": "Domyślne środowisko",
"settings.workspaces.default.description": "Wybierz, gdzie mają się rozpoczynać nowe sesje",
"settings.workspaces.default.lastUsed": "Ostatnio używane w każdym projekcie",
"settings.workspaces.default.local": "Katalog lokalny",
"settings.workspaces.default.new": "Nowy obszar roboczy",
"workspace.move.title": "Przenieś do obszaru roboczego",
"workspace.move.failed": "Nie udało się przenieść sesji",
"workspace.lifecycle.creating": "Tworzenie obszaru roboczego",
"workspace.lifecycle.created": "Utworzono obszar roboczy",
"workspace.lifecycle.moving": "Przenoszenie do obszaru roboczego",
"workspace.lifecycle.set": "Ustawiono obszar roboczy",
"workspace.type.local": "lokalna",
"workspace.type.sandbox": "piaskownica",
"workspace.create.failed.title": "Nie udało się utworzyć przestrzeni roboczej",
-34
View File
@@ -1122,40 +1122,6 @@ export const dict = {
"session.delete.button": "Удалить сессию",
"workspace.new": "Новое рабочее пространство",
"common.viewAll": "Показать все",
"session.new.workspace.local.tooltip": "Использовать текущую рабочую копию",
"session.new.workspace.new.tooltip": "Создать изолированную копию",
"session.new.workspace.fromBranch": "из {{branch}}",
"session.new.workspace.trigger.tooltip": "Выбрать, где запустить сессию",
"session.new.workspace.search.placeholder": "Поиск рабочих пространств",
"settings.tab.workspaces": "Рабочие пространства",
"settings.workspaces.filter.all": "Все проекты",
"settings.workspaces.empty": "Нет рабочих пространств",
"settings.workspaces.count.one": "{{count}} рабочее пространство",
"settings.workspaces.count.other": "{{count}} рабочих пространств",
"settings.workspaces.sessions.one": "{{count}} сессия в {{project}}",
"settings.workspaces.sessions.other": "{{count}} сессий в {{project}}",
"settings.workspaces.lastActiveSession": "Последний активный сеанс",
"settings.workspaces.deleteAll": "Удалить все рабочие пространства",
"settings.workspaces.deleteAll.confirm": "Удалить все рабочие пространства ({{count}})?",
"settings.workspaces.delete.warning":
"Каталог и ветка рабочего пространства будут удалены безвозвратно. Удаление выполняется только для чистого, неактивного пространства без связанных сеансов.",
"settings.workspaces.deleteAll.warning":
"Выбранные рабочие пространства ({{count}}) в {{project}} будут удалены безвозвратно, только если каждое из них чистое, неактивное и не имеет связанных сеансов.",
"settings.workspaces.delete.blocked.active": "Активное рабочее пространство нельзя удалить.",
"settings.workspaces.delete.blocked.linked":
"У этого рабочего пространства есть связанные сеансы, поэтому его нельзя удалить.",
"settings.workspaces.default.title": "Среда по умолчанию",
"settings.workspaces.default.description": "Выберите, где будут запускаться новые сеансы",
"settings.workspaces.default.lastUsed": "Последняя использованная среда проекта",
"settings.workspaces.default.local": "Локальный каталог",
"settings.workspaces.default.new": "Новое рабочее пространство",
"workspace.move.title": "Переместить в рабочую область",
"workspace.move.failed": "Не удалось переместить сеанс",
"workspace.lifecycle.creating": "Создание рабочей области",
"workspace.lifecycle.created": "Рабочая область создана",
"workspace.lifecycle.moving": "Перемещение в рабочую область",
"workspace.lifecycle.set": "Рабочая область задана",
"workspace.type.local": "локальное",
"workspace.type.sandbox": "песочница",
"workspace.create.failed.title": "Не удалось создать рабочее пространство",
-33
View File
@@ -1098,39 +1098,6 @@ export const dict = {
"session.delete.button": "ลบเซสชัน",
"workspace.new": "พื้นที่ทำงานใหม่",
"common.viewAll": "ดูทั้งหมด",
"session.new.workspace.local.tooltip": "ใช้ checkout ปัจจุบัน",
"session.new.workspace.new.tooltip": "สร้าง checkout ที่แยกอิสระ",
"session.new.workspace.fromBranch": "จาก {{branch}}",
"session.new.workspace.trigger.tooltip": "เลือกตำแหน่งที่จะเรียกใช้เซสชัน",
"session.new.workspace.search.placeholder": "ค้นหาพื้นที่ทำงาน",
"settings.tab.workspaces": "พื้นที่ทำงาน",
"settings.workspaces.filter.all": "โปรเจกต์ทั้งหมด",
"settings.workspaces.empty": "ไม่มีพื้นที่ทำงาน",
"settings.workspaces.count.one": "{{count}} พื้นที่ทำงาน",
"settings.workspaces.count.other": "{{count}} พื้นที่ทำงาน",
"settings.workspaces.sessions.one": "{{count}} เซสชันใน {{project}}",
"settings.workspaces.sessions.other": "{{count}} เซสชันใน {{project}}",
"settings.workspaces.lastActiveSession": "เซสชันที่ใช้งานล่าสุด",
"settings.workspaces.deleteAll": "ลบพื้นที่ทำงานทั้งหมด",
"settings.workspaces.deleteAll.confirm": "ลบพื้นที่ทำงานทั้งหมด {{count}} รายการหรือไม่",
"settings.workspaces.delete.warning":
"ไดเรกทอรีและสาขาของพื้นที่ทำงานจะถูกลบอย่างถาวร การลบจะดำเนินการต่อเมื่อพื้นที่ทำงานสะอาด ไม่ได้ใช้งาน และไม่มีเซสชันที่เชื่อมโยงเท่านั้น",
"settings.workspaces.deleteAll.warning":
"พื้นที่ทำงานที่เลือก {{count}} รายการใน {{project}} จะถูกลบอย่างถาวรเมื่อแต่ละรายการสะอาด ไม่ได้ใช้งาน และไม่มีเซสชันที่เชื่อมโยงเท่านั้น",
"settings.workspaces.delete.blocked.active": "ไม่สามารถลบพื้นที่ทำงานที่กำลังใช้งานอยู่ได้",
"settings.workspaces.delete.blocked.linked": "พื้นที่ทำงานนี้มีเซสชันที่เชื่อมโยงอยู่และไม่สามารถลบได้",
"settings.workspaces.default.title": "สภาพแวดล้อมเริ่มต้น",
"settings.workspaces.default.description": "เลือกตำแหน่งเริ่มต้นของเซสชันใหม่",
"settings.workspaces.default.lastUsed": "ใช้ล่าสุดในแต่ละโปรเจกต์",
"settings.workspaces.default.local": "ไดเรกทอรีในเครื่อง",
"settings.workspaces.default.new": "เวิร์กสเปซใหม่",
"workspace.move.title": "ย้ายไปยังพื้นที่ทำงาน",
"workspace.move.failed": "ย้ายเซสชันไม่สำเร็จ",
"workspace.lifecycle.creating": "กำลังสร้างพื้นที่ทำงาน",
"workspace.lifecycle.created": "สร้างพื้นที่ทำงานแล้ว",
"workspace.lifecycle.moving": "กำลังย้ายไปยังพื้นที่ทำงาน",
"workspace.lifecycle.set": "ตั้งค่าพื้นที่ทำงานแล้ว",
"workspace.type.local": "ในเครื่อง",
"workspace.type.sandbox": "แซนด์บ็อกซ์",
"workspace.create.failed.title": "ไม่สามารถสร้างพื้นที่ทำงาน",
-33
View File
@@ -1124,39 +1124,6 @@ export const dict = {
"session.delete.button": "Oturumu sil",
"workspace.new": "Yeni çalışma alanı",
"common.viewAll": "Tümünü göster",
"session.new.workspace.local.tooltip": "Mevcut çalışma kopyasını kullan",
"session.new.workspace.new.tooltip": "Yalıtılmış çalışma kopyası oluştur",
"session.new.workspace.fromBranch": "{{branch}} dalından",
"session.new.workspace.trigger.tooltip": "Oturumun nerede çalıştırılacağını seç",
"session.new.workspace.search.placeholder": "Çalışma alanlarında ara",
"settings.tab.workspaces": "Çalışma alanları",
"settings.workspaces.filter.all": "Tüm projeler",
"settings.workspaces.empty": "Çalışma alanı yok",
"settings.workspaces.count.one": "{{count}} çalışma alanı",
"settings.workspaces.count.other": "{{count}} çalışma alanı",
"settings.workspaces.sessions.one": "{{project}} projesinde {{count}} oturum",
"settings.workspaces.sessions.other": "{{project}} projesinde {{count}} oturum",
"settings.workspaces.lastActiveSession": "Son etkin oturum",
"settings.workspaces.deleteAll": "Tüm çalışma alanlarını sil",
"settings.workspaces.deleteAll.confirm": "{{count}} çalışma alanının tümü silinsin mi?",
"settings.workspaces.delete.warning":
"Çalışma alanı dizini ve dalı kalıcı olarak kaldırılacak. Silme yalnızca alan temiz, etkin değil ve bağlantılı oturumu yoksa devam eder.",
"settings.workspaces.deleteAll.warning":
"{{project}} içindeki seçili {{count}} çalışma alanı, yalnızca her biri temiz, etkin değil ve bağlantılı oturumu yoksa kalıcı olarak kaldırılacak.",
"settings.workspaces.delete.blocked.active": "Etkin çalışma alanı silinemez.",
"settings.workspaces.delete.blocked.linked": "Bu çalışma alanında bağlantılı oturumlar var ve silinemez.",
"settings.workspaces.default.title": "Varsayılan ortam",
"settings.workspaces.default.description": "Yeni oturumların nerede başlayacağını seçin",
"settings.workspaces.default.lastUsed": "Proje başına son kullanılan",
"settings.workspaces.default.local": "Yerel dizin",
"settings.workspaces.default.new": "Yeni çalışma alanı",
"workspace.move.title": "Çalışma alanına taşı",
"workspace.move.failed": "Oturum taşınamadı",
"workspace.lifecycle.creating": "Çalışma alanı oluşturuluyor",
"workspace.lifecycle.created": "Çalışma alanı oluşturuldu",
"workspace.lifecycle.moving": "Çalışma alanına taşınıyor",
"workspace.lifecycle.set": "Çalışma alanı ayarlandı",
"workspace.type.local": "yerel",
"workspace.type.sandbox": "korumalı alan",
"workspace.create.failed.title": "Çalışma alanı oluşturulamadı",
-33
View File
@@ -1227,39 +1227,6 @@ export const dict = {
"session.delete.button": "Видалити сесію",
"workspace.new": "Нова робоча область",
"common.viewAll": "Показати все",
"session.new.workspace.local.tooltip": "Використати поточну робочу копію",
"session.new.workspace.new.tooltip": "Створити ізольовану копію",
"session.new.workspace.fromBranch": "з {{branch}}",
"session.new.workspace.trigger.tooltip": "Вибрати, де запустити сесію",
"session.new.workspace.search.placeholder": "Пошук робочих областей",
"settings.tab.workspaces": "Робочі простори",
"settings.workspaces.filter.all": "Усі проєкти",
"settings.workspaces.empty": "Немає робочих просторів",
"settings.workspaces.count.one": "{{count}} робочий простір",
"settings.workspaces.count.other": "{{count}} робочих просторів",
"settings.workspaces.sessions.one": "{{count}} сесія в {{project}}",
"settings.workspaces.sessions.other": "{{count}} сесій у {{project}}",
"settings.workspaces.lastActiveSession": "Останній активний сеанс",
"settings.workspaces.deleteAll": "Видалити всі робочі простори",
"settings.workspaces.deleteAll.confirm": "Видалити всі робочі простори ({{count}})?",
"settings.workspaces.delete.warning":
"Каталог і гілку робочого простору буде видалено назавжди. Видалення виконується лише для чистого, неактивного простору без пов’язаних сеансів.",
"settings.workspaces.deleteAll.warning":
"Вибрані робочі простори ({{count}}) у {{project}} буде видалено назавжди, лише якщо кожен із них чистий, неактивний і не має пов’язаних сеансів.",
"settings.workspaces.delete.blocked.active": "Активний робочий простір не можна видалити.",
"settings.workspaces.delete.blocked.linked": "Цей робочий простір має пов’язані сеанси, тому його не можна видалити.",
"settings.workspaces.default.title": "Середовище за замовчуванням",
"settings.workspaces.default.description": "Виберіть, де починатимуться нові сеанси",
"settings.workspaces.default.lastUsed": "Останнє використане для кожного проєкту",
"settings.workspaces.default.local": "Локальний каталог",
"settings.workspaces.default.new": "Новий робочий простір",
"workspace.move.title": "Перемістити до робочого простору",
"workspace.move.failed": "Не вдалося перемістити сеанс",
"workspace.lifecycle.creating": "Створення робочого простору",
"workspace.lifecycle.created": "Робочий простір створено",
"workspace.lifecycle.moving": "Переміщення до робочого простору",
"workspace.lifecycle.set": "Робочий простір установлено",
"workspace.type.local": "локальна",
"workspace.type.sandbox": "пісочниця",
"workspace.create.failed.title": "Не вдалося створити робочу область",
-33
View File
@@ -1089,39 +1089,6 @@ export const dict = {
"session.delete.button": "删除会话",
"workspace.new": "新建工作区",
"common.viewAll": "查看全部",
"session.new.workspace.local.tooltip": "使用当前工作副本",
"session.new.workspace.new.tooltip": "创建隔离副本",
"session.new.workspace.fromBranch": "从 {{branch}}",
"session.new.workspace.trigger.tooltip": "选择会话的运行位置",
"session.new.workspace.search.placeholder": "搜索工作区",
"settings.tab.workspaces": "工作区",
"settings.workspaces.filter.all": "所有项目",
"settings.workspaces.empty": "没有工作区",
"settings.workspaces.count.one": "{{count}} 个工作区",
"settings.workspaces.count.other": "{{count}} 个工作区",
"settings.workspaces.sessions.one": "{{project}} 中有 {{count}} 个会话",
"settings.workspaces.sessions.other": "{{project}} 中有 {{count}} 个会话",
"settings.workspaces.lastActiveSession": "最近活跃的会话",
"settings.workspaces.deleteAll": "删除所有工作区",
"settings.workspaces.deleteAll.confirm": "删除全部 {{count}} 个工作区?",
"settings.workspaces.delete.warning":
"工作区目录和分支将被永久删除。仅当工作区干净、未处于活动状态且没有关联会话时才会继续删除。",
"settings.workspaces.deleteAll.warning":
"仅当 {{project}} 中选定的 {{count}} 个工作区均干净、未处于活动状态且没有关联会话时,才会永久删除它们。",
"settings.workspaces.delete.blocked.active": "无法删除当前活动的工作区。",
"settings.workspaces.delete.blocked.linked": "此工作区有关联会话,无法删除。",
"settings.workspaces.default.title": "默认环境",
"settings.workspaces.default.description": "选择新会话的启动位置",
"settings.workspaces.default.lastUsed": "每个项目上次使用的环境",
"settings.workspaces.default.local": "本地目录",
"settings.workspaces.default.new": "新工作区",
"workspace.move.title": "移动到工作区",
"workspace.move.failed": "无法移动会话",
"workspace.lifecycle.creating": "正在创建工作区",
"workspace.lifecycle.created": "工作区已创建",
"workspace.lifecycle.moving": "正在移动到工作区",
"workspace.lifecycle.set": "工作区已设置",
"workspace.type.local": "本地",
"workspace.type.sandbox": "沙盒",
"workspace.create.failed.title": "创建工作区失败",
-33
View File
@@ -1085,39 +1085,6 @@ export const dict = {
"session.delete.button": "刪除工作階段",
"workspace.new": "新增工作區",
"common.viewAll": "查看全部",
"session.new.workspace.local.tooltip": "使用目前的工作複本",
"session.new.workspace.new.tooltip": "建立隔離複本",
"session.new.workspace.fromBranch": "從 {{branch}}",
"session.new.workspace.trigger.tooltip": "選擇工作階段的執行位置",
"session.new.workspace.search.placeholder": "搜尋工作區",
"settings.tab.workspaces": "工作區",
"settings.workspaces.filter.all": "所有專案",
"settings.workspaces.empty": "沒有工作區",
"settings.workspaces.count.one": "{{count}} 個工作區",
"settings.workspaces.count.other": "{{count}} 個工作區",
"settings.workspaces.sessions.one": "{{project}} 中有 {{count}} 個工作階段",
"settings.workspaces.sessions.other": "{{project}} 中有 {{count}} 個工作階段",
"settings.workspaces.lastActiveSession": "最近活動的工作階段",
"settings.workspaces.deleteAll": "刪除所有工作區",
"settings.workspaces.deleteAll.confirm": "刪除全部 {{count}} 個工作區?",
"settings.workspaces.delete.warning":
"工作區目錄和分支將被永久刪除。只有在工作區乾淨、未處於活動狀態且沒有關聯工作階段時才會繼續刪除。",
"settings.workspaces.deleteAll.warning":
"只有在 {{project}} 中選取的 {{count}} 個工作區都乾淨、未處於活動狀態且沒有關聯工作階段時,才會永久刪除它們。",
"settings.workspaces.delete.blocked.active": "無法刪除目前作用中的工作區。",
"settings.workspaces.delete.blocked.linked": "此工作區有關聯工作階段,無法刪除。",
"settings.workspaces.default.title": "預設環境",
"settings.workspaces.default.description": "選擇新工作階段的啟動位置",
"settings.workspaces.default.lastUsed": "每個專案上次使用的環境",
"settings.workspaces.default.local": "本機目錄",
"settings.workspaces.default.new": "新工作區",
"workspace.move.title": "移動到工作區",
"workspace.move.failed": "無法移動工作階段",
"workspace.lifecycle.creating": "正在建立工作區",
"workspace.lifecycle.created": "工作區已建立",
"workspace.lifecycle.moving": "正在移動到工作區",
"workspace.lifecycle.set": "工作區已設定",
"workspace.type.local": "本機",
"workspace.type.sandbox": "沙盒",
"workspace.create.failed.title": "建立工作區失敗",
-5
View File
@@ -327,9 +327,4 @@
animation-range: 0 0.1px;
}
}
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
height: 24px;
padding-block: 0;
}
}
@@ -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, createSignal, onCleanup, type JSX, startTransition } from "solid-js"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
import { produce } from "solid-js/store"
import { useCommand } from "@/context/command"
import {
loadProjectedHomeSessionIndex,
loadHomeSessionIndex,
retainHomeSessions,
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"
@@ -19,13 +19,11 @@ import { displayName, errorMessage, projectForSession } from "@/pages/layout/hel
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { pathKey } from "@/utils/path-key"
import { showToast } from "@/utils/toast"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { Binary } from "@opencode-ai/core/util/binary"
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
@@ -68,17 +66,8 @@ export function createHomeSessionsController(home: HomeController) {
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
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,
}),
}
},
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.client.v2.session.list(input, options),
eventSequence,
signal,
)
@@ -90,16 +79,13 @@ export function createHomeSessionsController(home: HomeController) {
refetchOnMount: true,
refetchOnReconnect: true,
}))
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))),
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Number.NEGATIVE_INFINITY,
)
})
Date.now(),
),
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
@@ -108,21 +94,43 @@ export function createHomeSessionsController(home: HomeController) {
projectByID,
}),
)
const [visible, setVisible] = createSignal(HOME_SESSION_RENDER_BATCH)
let revealFrame: number | undefined
createEffect(() => {
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 records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
const groups = createMemo(() => groupSessions(records(), language))
const prefetched = new Set<string>()
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()
}
})
})
})
command.register("home.palette", () => [
{
id: "command.palette",
@@ -200,7 +208,6 @@ export function createHomeSessionsController(home: HomeController) {
const conn = home.server.focused()
const ctx = home.server.focusedContext()
if (!conn || !ctx) return
if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return
const [, setStore] = ctx.sync.child(session.directory)
if ((await ctx.sdk.protocol) !== "v1") return
await archiveHomeSession({
+12 -20
View File
@@ -1,15 +1,11 @@
import { createEffect, lazy, Suspense, type ParentProps } from "solid-js"
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { createStore } from "solid-js/store"
import { DebugBar } from "@/components/debug-bar"
import type { TitlebarUpdate } from "@/components/titlebar"
import { TabsInfoPopup } from "@/components/help-button"
import { Titlebar, 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 })
@@ -34,23 +30,19 @@ export default function NewLayout(props: ParentProps) {
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<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>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<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 />}
<Suspense>
<TabsInfoPopup />
</Suspense>
<TabsInfoPopup />
<ToastRegion v2 />
</div>
)
+12 -7
View File
@@ -60,7 +60,6 @@ import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import {
displayName,
effectiveWorkspaceOrder,
@@ -635,7 +634,7 @@ export default function LegacyLayout(props: ParentProps) {
running: number
}
const prefetchChunk = 50
const prefetchChunk = 200
const prefetchConcurrency = 2
const prefetchPendingLimit = 10
const span = 4
@@ -778,11 +777,18 @@ export default function LegacyLayout(props: ParentProps) {
}
createEffect(() => {
if (params.id) return
const sessions = currentSessions()
if (sessions.length === 0) return
const first = sessions[0]
if (first) prefetchSession(first, "high")
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)
})
function navigateSessionByOffset(offset: number) {
@@ -863,7 +869,6 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if (WorkspaceOperation.get(serverSDK().scope, session.id)?.status === "pending") return
if ((await serverSDK().protocol) !== "v1") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
@@ -1112,7 +1117,7 @@ export default function LegacyLayout(props: ParentProps) {
: import("@/components/dialog-settings")
void module.then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings sessionID={params.id} />)
dialog.show(() => <x.DialogSettings />)
})
}
+2 -18
View File
@@ -1,10 +1,7 @@
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useSettings } from "@/context/settings"
import { useTabs, type DraftTab } from "@/context/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource } from "solid-js"
import { createEffect, createResource } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
@@ -14,23 +11,10 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
export default function NewSessionPage() {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
onViewAll: openWorkspaces,
})
const workspace = createNewSessionWorkspaceController()
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
onSubmit: workspace.selection.remember,
})
const project = createPromptProjectController({
controls: draft.project.controls,
@@ -10,11 +10,7 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
export function createNewSessionDraftController(workspace: {
worktree: () => string
resetWorktree: () => void
onSubmit: () => void
}) {
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
const prompt = usePrompt()
const serverSync = useServerSync()
const comments = useComments()
@@ -40,10 +36,7 @@ export function createNewSessionDraftController(workspace: {
return workspace.worktree()
},
onNewSessionWorktreeReset: workspace.resetWorktree,
onSubmit: () => {
workspace.onSubmit()
comments.clear()
},
onSubmit: comments.clear,
})
createEffect(() => {
@@ -31,19 +31,6 @@ export function NewSessionView(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
const language = useLanguage()
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
createStore({ cardDismissed: false, used: false }),
)
const showCard = createMemo(
() => onboardingReady() && props.workspace.bar.visible() && !onboarding.cardDismissed && !onboarding.used,
)
const select = (value: string) => {
props.workspace.selection.set(value)
setOnboarding("used", true)
}
return (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
@@ -72,10 +59,8 @@ export function NewSessionView(props: {
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branch={props.workspace.bar.branch()}
onboarding={onboarding.cardDismissed && !onboarding.used}
onChange={select}
onChange={props.workspace.selection.set}
onDone={props.input.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
</Show>
</div>
@@ -83,27 +68,6 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<Show when={showCard()}>
<div class="absolute inset-x-0 bottom-[calc(25%-81px)] flex justify-center px-6 pointer-events-none">
<div class="pointer-events-auto flex w-[280px] flex-col gap-2 rounded-lg bg-v2-background-bg-base p-3 shadow-[var(--v2-elevation-floating)]">
<div class="flex items-start gap-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-muted">
<IconV2 name="workspace-isolated" class="shrink-0" />
<span class="min-w-0 flex-1">{language.t("workspace.onboarding.title")}</span>
<button
type="button"
class="flex size-5 shrink-0 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={() => setOnboarding("cardDismissed", true)}
>
<IconV2 name="xmark-small" />
</button>
</div>
<p class="text-[13px] font-[440] leading-4 tracking-[-0.04px] text-v2-text-text-muted">
{language.t("workspace.onboarding.description")}
</p>
</div>
</div>
</Show>
<ProviderTip />
</div>
</div>
@@ -179,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"
delay="intent"
openDelay={1000}
value={language.t("common.dismiss")}
>
<button
@@ -1,23 +1,20 @@
import { createMemo } from "solid-js"
import { createMemo, createSignal } from "solid-js"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { pathKey } from "@/utils/path-key"
import { isWorkspaceSelection, workspaceDefaultSelection, workspaceDirectories } from "@/utils/workspace"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
return input.fallback ?? "main"
return "main"
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
@@ -34,38 +31,18 @@ export function resolveNewSessionBranch(input: {
return input.worktreeBranch(input.worktree) ?? input.local
}
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
onViewAll: () => void
}) {
export function createNewSessionWorkspaceController() {
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const settings = useSettings()
const visible = createMemo(() => sync().project?.vcs === "git")
const selected = createMemo(() => {
const project = sync().project
const worktree = input.selected()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = sync().project
if (!project) return "main"
return workspaceDefaultSelection(
settings.workspaces.defaultDestination(),
settings.workspaces.lastUsed(serverSDK().scope, project.id),
)
})
const [worktree, setWorktree] = createSignal<string>()
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const value = createMemo(() =>
resolveNewSessionWorktree({
enabled: visible(),
selected: selected(),
selected: worktree(),
directory: sdk().directory,
projectWorktree: sync().project?.worktree,
fallback: fallback(),
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
@@ -77,31 +54,18 @@ export function createNewSessionWorkspaceController(input: {
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
}),
)
const remember = (worktree = value()) => {
const project = sync().project
if (!project) return
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
}
return {
selection: {
value,
reset: () => input.setSelected(undefined),
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
remember(worktree)
},
reset: () => setWorktree(),
set: (worktree: string) =>
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
},
project: {
root: projectRoot,
workspaces: () => {
const project = sync().project
return project ? workspaceDirectories(project) : []
},
workspaces: () => sync().project?.sandboxes ?? [],
git: () => sync().project?.vcs === "git",
openAll: input.onViewAll,
},
bar: {
visible,
+3 -47
View File
@@ -100,7 +100,6 @@ import { Persist, persisted } from "@/utils/persist"
import { extractPromptFromParts } from "@/utils/prompt"
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation"
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
import { createSessionOwnership } from "./session/session-ownership"
import { createSessionLineage } from "./session/session-lineage"
@@ -601,7 +600,6 @@ export default function Page() {
const [store, setStore] = createStore({
...sessionViewState(),
newSessionWorktree: "main",
sessionDetailsOpen: false,
deferRender: false,
})
@@ -692,12 +690,8 @@ export default function Page() {
queryFn: mode
? () =>
sdk()
.api.vcs.diff({
location: { directory: sdk().directory },
mode: mode === "git" ? "working" : mode,
context: 0,
})
.then((result) => result.data.map((diff) => ({ ...diff, patch: "" })))
.api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load vcs diff", { mode, error })
return []
@@ -705,19 +699,6 @@ export default function Page() {
: skipToken,
}
})
const sessionDetailsQuery = createQuery(() => ({
queryKey: [...vcsKey(), "git"] as const,
enabled: store.sessionDetailsOpen && sync().project?.vcs === "git",
queryFn: () =>
sdk()
.api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load session details diff", { error })
return []
}),
}))
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
const reviewDiffs = () => {
if (reviewMode() === "git" || reviewMode() === "branch")
@@ -1720,8 +1701,6 @@ export default function Page() {
}
const busy = (sessionID: string) => sync().data.session_working(sessionID)
const workspaceOperationPending = (sessionID: string) =>
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
const queuedFollowups = createMemo(() => {
const id = params.id
@@ -1735,20 +1714,8 @@ export default function Page() {
return followup.edit[id]
})
const workspaceMoveEligible = createMemo(() => {
const id = params.id
if (!id) return false
return canMoveSessionToWorkspace({
queued: followup.items[id]?.length ?? 0,
failed: !!followup.failed[id],
paused: !!followup.paused[id],
editing: !!followup.edit[id],
})
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
if (workspaceOperationPending(input.sessionID)) return
const owner = sessionOwnership.capture()
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
if (!item) return
@@ -1758,7 +1725,6 @@ export default function Page() {
const ok = await sendFollowupDraft({
api: sdk().api.session,
scope: serverSDK().scope,
sync: sync(),
serverSync: serverSync(),
draft: item,
@@ -1821,7 +1787,6 @@ export default function Page() {
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
if (workspaceOperationPending(sessionID)) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
@@ -1861,7 +1826,6 @@ export default function Page() {
const revertMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; messageID: string }) => {
if (workspaceOperationPending(input.sessionID)) return
const session = sdk().api.session
const target = sync()
const last = target.session.get(input.sessionID)?.revert
@@ -1884,7 +1848,6 @@ export default function Page() {
mutationFn: async (id: string) => {
const sessionID = params.id
if (!sessionID) return
if (workspaceOperationPending(sessionID)) return
const session = sdk().api.session
const target = sync()
@@ -1912,10 +1875,7 @@ export default function Page() {
},
}))
const reverting = createMemo(() => {
const id = params.id
return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id))
})
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
const revert = (input: { sessionID: string; messageID: string }) => {
@@ -1973,7 +1933,6 @@ export default function Page() {
if (isChildSession()) return
if (composer.blocked()) return
if (busy(sessionID)) return
if (workspaceOperationPending(sessionID)) return
void sendFollowup(sessionID, item.id)
})
@@ -2144,9 +2103,6 @@ export default function Page() {
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
diffs={sessionDetailsDiffs}
workspaceMoveEligible={workspaceMoveEligible()}
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
@@ -91,7 +91,7 @@ export function createPromptProjectControls() {
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
@@ -28,7 +28,6 @@ import {
type UserActions,
} from "@opencode-ai/session-ui/message-part"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { DiffChanges as DiffChangesV2 } from "@opencode-ai/ui/v2/diff-changes-v2"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -36,8 +35,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input"
@@ -52,7 +49,6 @@ import type {
AssistantMessage,
Message as MessageType,
Part as PartType,
Project,
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
@@ -67,7 +63,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
@@ -81,12 +76,6 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { isWorkspaceDirectory } from "@/utils/workspace"
import { useCommand } from "@/context/command"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
@@ -152,7 +141,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
)
}
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
const language = useLanguage()
const maxFiles = 10
const [state, setState] = createStore({
@@ -180,7 +169,6 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Elem
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
</span>
</Show>
{props.action}
</div>
<div data-component="session-turn-diffs-content">
<Accordion
@@ -235,188 +223,6 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Elem
)
}
function WorkspaceLocationLoader() {
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
return (
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
<For each={dots}>
{(position, index) => (
<span
class={`absolute size-1 bg-current ${position} animate-pulse`}
style={{ "animation-delay": `${index() * -180}ms` }}
/>
)}
</For>
</span>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
sessionID: string
project: Project
directory: string
messageID?: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ml-auto h-5 w-[167px]": inline(),
"h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03": !inline(),
invisible: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement={inline() ? "bottom-end" : "left-start"}
gutter={inline() ? 4 : -2}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
: "flex h-[46px] w-full items-center gap-1.5 rounded-b-[6px] px-3 pr-9 pt-[10px] text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03"
}
>
<IconV2 name={inline() ? "workspace-new" : "workspace"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
inline()
? "right-0 top-1/2"
: "hover-reveal right-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
aria-label={language.t("common.dismiss")}
onClick={(event) => {
event.stopPropagation()
props.onDismiss()
}}
>
<IconV2 name="xmark-small" />
</button>
</div>
)
}
function SessionSummaryPanel(props: {
project: Project
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
messageID?: string
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
}) {
const language = useLanguage()
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
const branch = () => props.branch ?? props.baseBranch
const row =
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
return (
<div
data-component="session-summary-panel"
class="w-[280px] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)]"
>
<div class="flex flex-col gap-1 px-0.5 py-1.5">
<div class={row}>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 flex-1 truncate">{displayName(props.project)}</span>
</div>
<Show
when={props.local}
fallback={
<div class={row}>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate">{location()}</span>
</div>
}
>
<SessionWorkspaceMenu
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement="left-start"
gutter={-2}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<IconV2 name="monitor" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
</Show>
<div class={row}>
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span>No branch</span>
<Show when={props.baseBranch}>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">Based on {props.baseBranch}</span>
</Show>
</span>
}
>
<span class="min-w-0 truncate">{branch()}</span>
</Show>
</div>
<button
type="button"
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>
{language.t(
props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
{ count: String(props.diffs.length) },
)}
</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChangesV2 changes={props.diffs} />
</Show>
</button>
</div>
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
function TimelineDiffView(props: { diff: SummaryDiff }) {
const fileComponent = useFileComponent()
const view = normalize(props.diff)
@@ -444,9 +250,6 @@ export function MessageTimeline(props: {
centered: boolean
setContentRef: (el: HTMLDivElement) => void
userMessages: UserMessage[]
diffs: Accessor<{ additions: number; deletions: number }[]>
workspaceMoveEligible: boolean
onSummaryOpenChange: (open: boolean) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
@@ -456,14 +259,12 @@ export function MessageTimeline(props: {
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const command = useCommand()
const { params, sessionKey } = useSessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey)
@@ -494,44 +295,6 @@ export function MessageTimeline(props: {
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const sessionDirectory = createMemo(() => info()?.directory ?? sdk().directory)
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
const [summaryOpen, setSummaryOpen] = createSignal(false)
const setSummary = (open: boolean) => {
setSummaryOpen(open)
props.onSummaryOpenChange(open)
}
const sessionDiffs = createMemo(props.diffs)
createEffect(
on(sessionID, () => {
setSummary(false)
setWorkspaceSuggestionDismissed(false)
}),
)
const turnPadding = () => (settings.general.newLayoutDesigns() ? "px-3" : "px-4 md:px-5")
const workspaceOperation = createMemo(() => {
const id = sessionID()
if (!id) return
return WorkspaceOperation.get(serverSDK().scope, id)
})
const lifecycleTitle = createMemo(() => {
const operation = workspaceOperation()
if (operation?.status === "pending") {
return {
kind: "pending" as const,
workspace: true,
text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"),
}
}
if (operation?.type === "create" && titleValue()?.startsWith("New session"))
return { kind: "created" as const, workspace: true, text: language.t("workspace.lifecycle.created") }
if (titleValue()?.startsWith("New session"))
return { kind: "starting" as const, workspace: workspaceSession(), text: language.t("workspace.lifecycle.starting") }
return
})
const workspaceOperationPending = (sessionID: string) =>
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@@ -563,7 +326,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID() || workspaceSession()))
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@@ -572,19 +335,6 @@ export function MessageTimeline(props: {
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
extensionRevision: workspaceOperation,
afterTurn: (message) => {
const operation = workspaceOperation()
if (!operation) return []
if (operation.messageID !== message.id && (operation.messageID || message.id !== props.userMessages.at(-1)?.id))
return []
return [
new TimelineRow.WorkspaceLifecycle({
userMessageID: message.id,
notice: { type: "operation", operation },
}),
]
},
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@@ -1057,7 +807,6 @@ export function MessageTimeline(props: {
}
const archiveSession = async (sessionID: string) => {
if (workspaceOperationPending(sessionID)) return
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
@@ -1088,7 +837,6 @@ export function MessageTimeline(props: {
}
const deleteSession = async (sessionID: string) => {
if (workspaceOperationPending(sessionID)) return false
const session = sync().session.get(sessionID)
if (!session) return false
@@ -1182,7 +930,7 @@ export function MessageTimeline(props: {
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" disabled={workspaceOperationPending(props.sessionID)} onClick={handleDelete}>
<ButtonV2 variant="danger" onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
@@ -1201,12 +949,7 @@ export function MessageTimeline(props: {
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button
variant="primary"
size="large"
disabled={workspaceOperationPending(props.sessionID)}
onClick={handleDelete}
>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
@@ -1336,8 +1079,7 @@ export function MessageTimeline(props: {
data-timeline-row={input.row()._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[720px]": props.centered && settings.general.newLayoutDesigns(),
"md:max-w-200 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:mx-auto": props.centered,
"pt-3": previousAssistantPart(),
}}
@@ -1360,7 +1102,7 @@ export function MessageTimeline(props: {
)
return (
<TimelineRowFrame row={commentStripRow}>
<div class={`w-full pb-2 ${turnPadding()}`}>
<div class="w-full px-4 md:px-5 pb-2">
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
@@ -1411,7 +1153,7 @@ export function MessageTimeline(props: {
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-content" aria-live="off">
<Message
message={message()}
@@ -1427,62 +1169,11 @@ export function MessageTimeline(props: {
</TimelineRowFrame>
)
}
case "WorkspaceLifecycle": {
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
const operation = createMemo(() => {
const rowOperation = workspaceRow().notice.operation
const current = WorkspaceOperation.get(serverSDK().scope, sessionID()!)
if (current?.messageID === workspaceRow().userMessageID) return current
if (
!rowOperation.messageID &&
current?.directory === rowOperation.directory &&
current.type === rowOperation.type
)
return current
return rowOperation
})
const pending = () => operation().status === "pending"
const status = () => {
if (operation().status === "failed") return language.t("workspace.move.failed")
if (operation().type === "create")
return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created")
return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set")
}
const directory = () => getFilename(operation().directory)
return (
<TimelineRowFrame row={workspaceRow}>
<div class={`w-full ${turnPadding()}`} aria-live="polite">
<div class="flex h-11 w-full items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
<Show when={!pending()} fallback={<TextShimmer text={status()} />}>
<span
classList={{
"flex min-w-0 items-center gap-1.5": true,
"text-v2-state-fg-danger": operation().status === "failed",
}}
>
<Show when={operation().status !== "failed"}>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
</Show>
<Show when={directory()}>
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
</Show>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-faint lowercase"}>
{status()}
</span>
</span>
</Show>
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
</div>
</div>
</TimelineRowFrame>
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={turnDividerRow}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
@@ -1498,7 +1189,7 @@ export function MessageTimeline(props: {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={assistantPartRow}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
@@ -1513,7 +1204,7 @@ export function MessageTimeline(props: {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={thinkingRow}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
@@ -1526,7 +1217,7 @@ export function MessageTimeline(props: {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={retryRow}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
@@ -1534,35 +1225,10 @@ export function MessageTimeline(props: {
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
const canMove = () =>
settings.general.newLayoutDesigns() &&
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
!workspaceSession() &&
props.workspaceMoveEligible &&
sync().project?.vcs === "git" &&
sessionStatus().type === "idle"
return (
<TimelineRowFrame row={diffSummaryRow}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineDiffSummaryRow
diffs={diffSummaryRow().diffs}
action={
<Show when={canMove() && sync().project}>
{(project) => (
<WorkspaceMoveAction
variant="inline"
eligible={props.workspaceMoveEligible}
sessionID={sessionID()!}
project={project()}
directory={sessionDirectory()}
messageID={diffSummaryRow().userMessageID}
dismissed={workspaceSuggestionDismissed()}
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
/>
)}
</Show>
}
/>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
</div>
</TimelineRowFrame>
)
@@ -1571,7 +1237,7 @@ export function MessageTimeline(props: {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={errorRow}>
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<Card variant="error" class="error-card">
{errorRow().text}
</Card>
@@ -1653,7 +1319,7 @@ export function MessageTimeline(props: {
}
return (
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
<div class="relative w-full h-full min-w-0">
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
@@ -1748,39 +1414,6 @@ export function MessageTimeline(props: {
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show when={settings.general.newLayoutDesigns()}>
<Show
when={workspaceOperation()?.status !== "pending"}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<WorkspaceLocationLoader />
</span>
}
>
<Show
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<IconV2 name="monitor" />
</span>
}
>
<TooltipV2
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
aria-label={sessionDirectory()}
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted"
>
<IconV2 name="workspace-isolated" />
</span>
</TooltipV2>
</Show>
</Show>
</Show>
<Show when={parentID()}>
<button
type="button"
@@ -1798,73 +1431,57 @@ export function MessageTimeline(props: {
/
</span>
</Show>
<Show
when={!lifecycleTitle()}
fallback={
<span
class="px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px]"
classList={{ "text-v2-text-text-base": lifecycleTitle()?.kind === "created" }}
aria-live="polite"
>
<Show when={lifecycleTitle()?.kind !== "created"} fallback={lifecycleTitle()?.text}>
<TextShimmer text={lifecycleTitle()!.text} />
</Show>
</span>
}
>
<Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
<Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]":
!settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
onClick={openTitleEditor}
>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</div>
@@ -1882,47 +1499,6 @@ export function MessageTimeline(props: {
placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={settings.general.newLayoutDesigns() && !parentID() && sync().project}>
{(project) => (
<KobaltePopover
open={summaryOpen()}
placement="bottom-end"
gutter={6}
onOpenChange={setSummary}
>
<KobaltePopover.Trigger
as={IconButtonV2}
icon={<IconV2 name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label="Session details"
aria-expanded={summaryOpen()}
/>
<KobaltePopover.Portal>
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={sync().data.vcs?.branch}
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
messageID={props.userMessages.at(-1)?.id}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
command.trigger("review.toggle")
}}
/>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)}
</Show>
<Show when={!parentID()}>
<Show
when={settings.general.newLayoutDesigns()}
@@ -1988,15 +1564,11 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item
disabled={workspaceOperationPending(id)}
onSelect={() => void archiveSession(id)}
>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={workspaceOperationPending(id)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
@@ -2063,17 +1635,11 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item
disabled={workspaceOperationPending(id)}
onSelect={() => void archiveSession(id)}
>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
{language.t("common.archive")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item
disabled={workspaceOperationPending(id)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { appendTurnExtensions, reuseTimelineRows } from "./row-reconciliation"
import { reuseTimelineRows } from "./row-reconciliation"
import { TimelineRow } from "./timeline-row"
const context = (key: string, partIDs: string[], userMessageID = "user-1") =>
@@ -94,20 +94,3 @@ describe("reuseTimelineRows", () => {
reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex]))
})
})
test("appends turn extensions after projected turn rows", () => {
const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })]
const lifecycle = new TimelineRow.WorkspaceLifecycle({
userMessageID: "user-1",
notice: {
type: "operation",
operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" },
},
})
expect(appendTurnExtensions(rows, [lifecycle]).map((row) => row._tag)).toEqual([
"UserMessage",
"DiffSummary",
"WorkspaceLifecycle",
])
})
@@ -14,8 +14,6 @@ export function createTimelineProjection(input: {
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
extensionRevision?: Accessor<unknown>
afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[]
}) {
const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const)))
const assistantMessagesByParent = createMemo(() => {
@@ -31,9 +29,8 @@ export function createTimelineProjection(input: {
})
return result
})
const projection = createMemo(() => {
const extension = input.extensionRevision?.()
return Timeline.constructSessionMessageRows(
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
@@ -41,9 +38,8 @@ export function createTimelineProjection(input: {
input.status().type,
input.inlineComments(),
input.userMessages(),
input.extensionRevision && extension === undefined ? undefined : input.afterTurn,
)
})
),
)
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
@@ -3,11 +3,6 @@ import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
export function appendTurnExtensions(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
rows.push(...extensions)
return rows
}
export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) {
if (!previous?.length) return rows
const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const))
@@ -168,38 +168,6 @@ describe("current session timeline rows", () => {
])
})
test("appends lifecycle extensions after current turns", () => {
const source = [
{ id: "msg_user", type: "user", text: "question", time: { created: 1 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
normalized.messages.filter((message) => message.role === "user"),
(message) => [
new TimelineRow.WorkspaceLifecycle({
userMessageID: message.id,
notice: {
type: "operation",
operation: { type: "move", status: "complete", directory: "/workspace", messageID: message.id },
},
}),
],
)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"workspace-lifecycle:msg_user:operation",
])
})
test("removes a failed assistant error when the turn continues streaming", () => {
const source = [
{ id: "msg_user", type: "user", text: "recover", time: { created: 1 } },
@@ -4,7 +4,6 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { appendTurnExtensions } from "./row-reconciliation"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@@ -28,10 +27,6 @@ export type TimelineRowMap = {
}
Thinking: { userMessageID: string; reasoningHeading?: string }
Retry: { userMessageID: string }
WorkspaceLifecycle: {
userMessageID: string
notice: TimelineRow.WorkspaceLifecycle["notice"]
}
DiffSummary: { userMessageID: string; diffs: SummaryDiff[] }
Error: { userMessageID: string; text: string }
}
@@ -45,7 +40,6 @@ export namespace Timeline {
status: SessionStatus["type"],
inlineComments: boolean,
projectedUserMessages: UserMessage[],
afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[],
) {
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
@@ -88,8 +82,8 @@ export namespace Timeline {
const activeMessageID = turns.at(-1)?.user.id
return {
activeMessageID,
rows: turns.flatMap((turn, index) => {
const rows = constructMessageRows(
rows: turns.flatMap((turn, index) =>
constructMessageRows(
turn.user,
getMessageParts,
turn.assistants,
@@ -98,10 +92,8 @@ export namespace Timeline {
status,
turn.user.id === activeMessageID,
inlineComments,
)
if (!afterTurn) return rows
return appendTurnExtensions(rows, afterTurn(turn.user))
}),
),
),
}
}
@@ -1,7 +1,6 @@
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { PartGroup } from "@opencode-ai/session-ui/message-part"
import { Data, Equal } from "effect"
import type { WorkspaceOperationState } from "@/utils/workspace-operation"
export type SummaryDiff = SnapshotFileDiff & { file: string }
@@ -40,10 +39,6 @@ export namespace TimelineRow {
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{
userMessageID: string
notice: { type: "operation"; operation: WorkspaceOperationState }
}> {}
export type TimelineRow =
| TurnGap
@@ -55,7 +50,6 @@ export namespace TimelineRow {
| DiffSummary
| Error
| Retry
| WorkspaceLifecycle
export const key = (row: TimelineRow) => {
switch (row._tag) {
@@ -77,8 +71,6 @@ export namespace TimelineRow {
return `error:${row.userMessageID}`
case "Retry":
return `retry:${row.userMessageID}`
case "WorkspaceLifecycle":
return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}`
}
}
@@ -19,7 +19,6 @@ import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionOwnership } from "./session-ownership"
import { useLocal } from "@/context/local"
import { WorkspaceOperation } from "@/utils/workspace-operation"
export type SessionCommandContext = {
navigateMessageByOffset: (offset: number) => void
@@ -74,8 +73,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (!id) return
return sync().session.get(id)
}
const workspaceOperationPending = (sessionID: string) =>
WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending"
const hasReview = () => !!params.id
const normalizeTab = (tab: string) => {
if (!tab.startsWith("file://")) return tab
@@ -308,7 +305,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (workspaceOperationPending(sessionID)) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const directory = sdk().directory
@@ -337,7 +333,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
if (workspaceOperationPending(sessionID)) return
const owner = sessionOwnership.capture()
const session = sdk().api.session
const messages = userMessages()
@@ -370,7 +365,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
if (workspaceOperationPending(sessionID)) return
const model = local.model.current()
if (!model) {
@@ -388,9 +382,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const fork = () => {
const sessionID = params.id
if (!sessionID) return
if (workspaceOperationPending(sessionID)) return
void openDialog(
() => import("@/components/dialog-fork"),
(x) => dialog.show(() => <x.DialogFork />),
@@ -440,7 +431,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
@@ -448,7 +439,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID || workspaceOperationPending(params.id),
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
@@ -456,7 +447,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
@@ -464,7 +455,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id),
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
]
@@ -1,8 +1,6 @@
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">,
@@ -50,8 +48,6 @@ 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 }
}
+51 -30
View File
@@ -1,8 +1,7 @@
import type { ServerApi } from "./server"
import type { ServerProtocol } from "./server-protocol"
import type { AgentPartInput, FilePartInput, OpencodeClient, TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client"
import type {
FileDiffInfo,
Project,
ProjectCurrent,
SessionApi,
@@ -16,7 +15,6 @@ import type {
SessionShellInput,
SessionShellOutput,
} from "@opencode-ai/client/promise"
import { legacySessionInfo } from "@/context/session-message-decode"
type LegacyClient = OpencodeClient
type LegacyFor = (directory?: string) => LegacyClient
@@ -53,8 +51,6 @@ type CompatibleInput = {
current: ServerApi
legacy: LegacyFor
directory?: string
decodeVcsDiff: (buffer: ArrayBuffer) => Promise<FileDiffInfo[]>
decodeSessionList: (buffer: ArrayBuffer) => Promise<SessionInfo[]>
}
function mime(uri: string) {
@@ -62,6 +58,31 @@ 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(
@@ -127,34 +148,29 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
search: value.search,
limit: value.limit,
},
{ ...options, parseAs: "arrayBuffer" },
options,
)
if (!(result.data instanceof ArrayBuffer)) throw new Error("Session list response is not an ArrayBuffer")
return { data: await input.decodeSessionList(result.data), cursor: {} }
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: {} }
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: {} }
},
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 legacySessionInfo(result.data)
return sessionInfo(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 legacySessionInfo(result.data)
return sessionInfo(result.data)
},
async active() {
const result = await legacy().session.status()
@@ -176,7 +192,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 legacySessionInfo(result.data)
return sessionInfo(result.data)
},
async interrupt(value: Parameters<ServerApi["session"]["interrupt"]>[0]) {
await legacy().session.abort(value)
@@ -325,15 +341,20 @@ 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,
},
{ parseAs: "arrayBuffer" },
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,
)
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: {
-20
View File
@@ -1,20 +0,0 @@
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",
}),
)
}
@@ -1,60 +0,0 @@
import type { FileDiffInfo } from "@opencode-ai/client/promise"
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(new URL("./vcs-diff-decoder.worker.ts", import.meta.url), { 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)
}
@@ -1,13 +0,0 @@
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 {}
@@ -1,39 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ServerScope } from "./server-scope"
import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation"
test("workspace moves require settled followup state", () => {
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true)
expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false)
expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false)
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false)
expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false)
})
describe("WorkspaceOperation", () => {
test("reacts to operation completion", () => {
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
WorkspaceOperation.complete(ServerScope.local, "session")
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
})
test("ignores completion for a different destination", () => {
WorkspaceOperation.start(ServerScope.local, "destination", "move", "/workspace/expected")
WorkspaceOperation.complete(ServerScope.local, "destination", "/workspace/other")
expect(WorkspaceOperation.get(ServerScope.local, "destination")?.status).toBe("pending")
})
test("does not downgrade a completed move after cleanup fails", () => {
WorkspaceOperation.start(ServerScope.local, "cleanup", "move", "/workspace")
WorkspaceOperation.complete(ServerScope.local, "cleanup")
WorkspaceOperation.fail(ServerScope.local, "cleanup", "source cleanup failed")
expect(WorkspaceOperation.get(ServerScope.local, "cleanup")?.status).toBe("complete")
})
test("settles a pending create from its worktree directory", () => {
WorkspaceOperation.start(ServerScope.local, "create", "create", "/workspace/create")
WorkspaceOperation.completeCreate(ServerScope.local, "/workspace/create")
expect(WorkspaceOperation.get(ServerScope.local, "create")?.status).toBe("complete")
})
})
@@ -1,74 +0,0 @@
import { createSignal } from "solid-js"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import { pathKey } from "@/utils/path-key"
export type WorkspaceOperationType = "create" | "move"
export type WorkspaceOperationState = {
type: WorkspaceOperationType
status: "pending" | "complete" | "failed"
directory: string
messageID?: string
message?: string
}
export function canMoveSessionToWorkspace(input: {
queued: number
failed: boolean
paused: boolean
editing: boolean
}) {
return input.queued === 0 && !input.failed && !input.paused && !input.editing
}
const state = new Map<string, WorkspaceOperationState>()
const [version, setVersion] = createSignal(0)
const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID)
const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => {
if (!state.has(key(scope, sessionID)) && state.size >= 100) {
const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value
if (terminal) state.delete(terminal)
}
state.set(key(scope, sessionID), value)
setVersion((current) => current + 1)
}
const settleCreate = (scope: ServerScope, directory: string, status: "complete" | "failed", message?: string) => {
const prefix = ScopedKey.prefix(scope)
const target = pathKey(directory)
const matches = [...state].filter(
([id, item]) =>
id.startsWith(prefix) &&
item.type === "create" &&
item.status === "pending" &&
pathKey(item.directory) === target,
)
if (matches.length === 0) return
matches.forEach(([id, item]) => state.set(id, { ...item, status, message }))
setVersion((current) => current + 1)
}
export const WorkspaceOperation = {
get(scope: ServerScope, sessionID: string) {
version()
return state.get(key(scope, sessionID))
},
start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) {
write(scope, sessionID, { type, directory, messageID, status: "pending" })
},
complete(scope: ServerScope, sessionID: string, directory?: string) {
const current = state.get(key(scope, sessionID))
if (!current) return
if (directory && pathKey(directory) !== pathKey(current.directory)) return
write(scope, sessionID, { ...current, status: "complete" })
},
completeCreate(scope: ServerScope, directory: string) {
settleCreate(scope, directory, "complete")
},
failCreate(scope: ServerScope, directory: string, message: string) {
settleCreate(scope, directory, "failed", message)
},
fail(scope: ServerScope, sessionID: string, message: string) {
const current = state.get(key(scope, sessionID))
if (!current || current.status === "complete") return
write(scope, sessionID, { ...current, status: "failed", message })
},
}

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