Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 5562652f63 test(opencode): expect empty response retry 2026-08-04 23:13:23 +00:00
Aiden Cline 031510e1c3 fix(opencode): retry empty unknown responses 2026-08-04 22:52:01 +00:00
451 changed files with 3923 additions and 57657 deletions
-1
View File
@@ -322,7 +322,6 @@ jobs:
working-directory: packages/desktop working-directory: packages/desktop
env: env:
NODE_OPTIONS: --max-old-space-size=4096 NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }} SENTRY_ORG: ${{ vars.SENTRY_ORG }}
@@ -1,63 +0,0 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
+788 -617
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-VHsoK4LFUOygKqBwqasLBSumMqsdYJzC+11l+MGiGhg=", "x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=",
"aarch64-linux": "sha256-kFmiXl3iTzWgGCwPJc7BumbiK6BfJp7weoSHkXKg3kU=", "aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=",
"aarch64-darwin": "sha256-oQ+Cl/619Og4BV/s8J/oFQfAi7yxpelcx8TTm6N26ng=", "aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=",
"x86_64-darwin": "sha256-3SlyLJ3AbX6/v+svdBFpIu/ngzOt5RNhpGijPLsGd04=" "x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY="
} }
} }
+1 -2
View File
@@ -158,7 +158,6 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch"
} }
} }
-9
View File
@@ -22,18 +22,9 @@
## Localization ## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.
- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts.
- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- For developer-facing terminology, prefer the words already used by the target language's developer community over literal dictionary translations. Cross-check maintained localized developer products such as Firefox, KDE, and VS Code; use at least two independent corpora when they are available. If established practice keeps an English loanword or acronym, keep it rather than inventing a translation.
- Translate complete UI phrases in context. A glossary hit is evidence, not permission to translate word-by-word. Check terse labels such as session, prompt, agent, model, fork, shell, terminal, workspace, and worktree in the same grammatical role before choosing a term.
- Before a locale is ready, audit recurring concepts for one consistent translation and review every value that still equals English. Classify retained English as a product name, provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing; translate unexplained leftovers.
- In translation review notes, name the corpora used and call out uncertain or region-specific terminology so native speakers can focus review where it matters.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. - Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Tool Calling ## Tool Calling
-16
View File
@@ -1,16 +0,0 @@
## Required Reading
- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides.
- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply.
## Test Hygiene
- Test user-visible behavior with isolated, deterministic data and scoped, unique locators.
- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors.
- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes.
- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead.
- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires.
- Register event and network waits before the action that triggers them.
- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome.
- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes.
- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass.
-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. 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 ## Chrome traces
Set `OPENCODE_PERFORMANCE_TRACE_DIR` to emit a standard Chrome DevTools trace for every benchmark page automatically: 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"
@@ -197,9 +197,7 @@ export async function setupTimeline(
) )
}, },
async waitForPart(partID: string) { async waitForPart(partID: string) {
const part = page.locator(`[data-timeline-part-id="${partID}"]`) await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible()
await expect(part).toHaveCount(1)
await expect(part).toBeVisible()
}, },
} }
} }
@@ -18,7 +18,6 @@ test("opens the comment editor when code is clicked", async ({ page }) => {
await line.click() await line.click()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
}) })
test("opens the comment editor when a line number is clicked", async ({ page }) => { test("opens the comment editor when a line number is clicked", async ({ page }) => {
@@ -28,7 +27,6 @@ test("opens the comment editor when a line number is clicked", async ({ page })
await lineNumber.click() await lineNumber.click()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
}) })
test("opens the comment editor for a line number range", async ({ page }) => { test("opens the comment editor for a line number range", async ({ page }) => {
@@ -38,10 +36,15 @@ test("opens the comment editor for a line number range", async ({ page }) => {
await expectAppVisible(start) await expectAppVisible(start)
await expectAppVisible(end) await expectAppVisible(end)
await start.dragTo(end) const from = await start.boundingBox()
const to = await end.boundingBox()
if (!from || !to) throw new Error("Missing line number bounds")
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
await page.mouse.down()
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2)
await page.mouse.up()
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3")
}) })
test("shows a comment button when a line number is hovered", async ({ page }) => { test("shows a comment button when a line number is hovered", async ({ page }) => {
@@ -51,38 +54,31 @@ test("shows a comment button when a line number is hovered", async ({ page }) =>
const comment = review.getByRole("button", { name: "Comment", exact: true }) const comment = review.getByRole("button", { name: "Comment", exact: true })
await expect(async () => { await expect(async () => {
await page.mouse.move(0, 0)
await lineNumber.hover() await lineNumber.hover()
await expect(lineNumber).toHaveAttribute("data-hovered", "") await expect(comment).toBeVisible({ timeout: 500 })
await expect(comment).toHaveCount(1) await comment.click({ timeout: 500 })
await expect(comment).toHaveCSS("pointer-events", "auto") }).toPass()
await comment.focus()
await expect(comment).toBeFocused()
}).toPass({ timeout: 10_000 })
await comment.press("Enter")
await expect(review.getByRole("textbox")).toBeVisible() await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
}) })
test("stages a submitted line comment in the prompt context", async ({ page }) => { test("stages a submitted line comment in the prompt context", async ({ page }) => {
const requests: string[] = []
page.on("request", (request) => { page.on("request", (request) => {
expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`)
}) })
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await review.getByText("export const value = 'after'", { exact: true }).click() await review.getByText("export const value = 'after'", { exact: true }).click()
const textbox = review.getByRole("textbox") await review.getByRole("textbox").fill("Use the existing value instead")
await expect(textbox).toBeVisible() await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2")
await textbox.fill("Use the existing value instead")
const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]')
await expect(submit).toBeEnabled()
await submit.click()
await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible()
await page.getByRole("tab", { name: "Session" }).click() await page.getByRole("tab", { name: "Session" }).click()
const context = page.getByText("Use the existing value instead", { exact: true }).last() const context = page.getByText("Use the existing value instead", { exact: true }).last()
await expect(context).toBeVisible() await expect(context).toBeVisible()
await expect(context.locator("..")).toContainText("review.ts:2") await expect(context.locator("..")).toContainText("review.ts:2")
expect(requests).toEqual([])
}) })
async function openReview(page: Page) { async function openReview(page: Page) {
@@ -148,22 +144,15 @@ async function openReview(page: Page) {
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const changes = page.getByRole("tab", { name: "Changes" }) const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff")
const diffResponse = page.waitForResponse( await page.getByRole("tab", { name: "Changes" }).click()
(response) =>
response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff",
)
await changes.click()
expect((await (await diffResponse).json()).data).toHaveLength(1) expect((await (await diffResponse).json()).data).toHaveLength(1)
await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/)
const review = page.locator('[data-component="session-review"]') const review = page.locator('[data-component="session-review"]')
await expectAppVisible(review) await expectAppVisible(review)
const file = review.locator('[data-file="src/review.ts"]') await review
await expectAppVisible(file) .getByRole("heading", { name: /review\.ts/ })
const trigger = file.getByRole("button", { expanded: false }) .getByRole("button")
await expect(trigger).toHaveCount(1) .first()
await trigger.click() .click()
await expect(file.getByRole("button", { expanded: true })).toBeVisible()
await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible()
} }
@@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async
}) })
await mockServer(page, { questions: [] }) await mockServer(page, { questions: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection({ path: "/api/event" }) await transport.waitForConnection()
await expectSessionTitle(page, title) await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
@@ -132,40 +132,32 @@ test("restores the draft caret before typing after a request dock closes", async
}), }),
) )
.toBe(cursor) .toBe(cursor)
await transport.send( await transport.send({
{ directory,
directory, payload: {
payload: { type: "question.asked",
type: "question.asked", properties: {
properties: { id: "question-caret",
id: "question-caret", sessionID,
sessionID, questions: [
questions: [ {
{ header: "Continue",
header: "Continue", question: "Continue?",
question: "Continue?", options: [{ label: "Yes", description: "Continue the session" }],
options: [{ label: "Yes", description: "Continue the session" }], },
}, ],
], tool: { messageID: "message-caret", callID: "call-caret" },
tool: { messageID: "message-caret", callID: "call-caret" },
},
}, },
}, },
undefined, })
"/api/event",
)
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
await expect(question).toBeVisible() await expect(question).toBeVisible()
await expect(editor).toHaveCount(0) await expect(editor).toHaveCount(0)
await transport.send( await transport.send({
{ directory,
directory, payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } },
payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, })
},
undefined,
"/api/event",
)
await expect(question).toHaveCount(0) await expect(question).toHaveCount(0)
await expect(editor).toBeVisible() await expect(editor).toBeVisible()
await page.keyboard.press("x") await page.keyboard.press("x")
@@ -18,7 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits" import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20 const initialPageSize = 20
const historyPageSize = 50 const historyPageSize = 200
const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) => const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], { assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`, id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
@@ -1,8 +1,9 @@
import { expect, test, type Page } from "@playwright/test" import { expect, test } from "@playwright/test"
import { import {
assistantMessage, assistantMessage,
partUpdated, partUpdated,
setupTimeline, setupTimeline,
status,
textPart, textPart,
userMessage, userMessage,
} from "../performance/timeline-stability/fixture" } from "../performance/timeline-stability/fixture"
@@ -16,7 +17,7 @@ test("keeps one connection open while delivering multiple events", async ({ page
await timeline.waitForPart("prt_transport_first") await timeline.waitForPart("prt_transport_first")
await timeline.waitForPart("prt_transport_second") await timeline.waitForPart("prt_transport_second")
expect(first.connectionID).toBe(second.connectionID) expect(first.connectionID).toBe(second.connectionID)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) expect(await timeline.transport.connections()).toHaveLength(1)
expect(await timeline.transport.acknowledgements()).toHaveLength(2) expect(await timeline.transport.acknowledgements()).toHaveLength(2)
}) })
@@ -50,28 +51,20 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
}) })
test("delivers server heartbeat without mutating the timeline", async ({ page }) => { test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
const sentinelID = "prt_transport_heartbeat_sentinel"
const timeline = await setupTimeline(page, { const timeline = await setupTimeline(page, {
messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])],
}) })
await timeline.waitForPart("prt_transport_steady") const before = await page.locator("[data-timeline-row]").allTextContents()
const before = await stableTimelineRows(page)
await timeline.transport.writeRaw(": heartbeat\n\n") await timeline.transport.heartbeat()
await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) await timeline.settle()
await timeline.waitForPart(sentinelID)
await expect expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before)
.poll(async () => { expect(await timeline.transport.connections()).toHaveLength(1)
const rows = await timelineRows(page)
return rows.filter((row) => before.some((item) => item.key === row.key))
})
.toEqual(before)
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
}) })
test("reconnects after a clean close", async ({ page }) => { test("reconnects after a clean close", async ({ page }) => {
const timeline = await setupTimeline(page) const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.close() await timeline.transport.close()
@@ -84,30 +77,27 @@ test("reconnects after a clean close", async ({ page }) => {
}) })
test("reconnects after a stream error", async ({ page }) => { test("reconnects after a stream error", async ({ page }) => {
const timeline = await setupTimeline(page) const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.waitForConnection() const first = await timeline.transport.waitForConnection()
await timeline.transport.error("contract failure") await timeline.transport.error("contract failure")
const second = await timeline.transport.waitForConnection({ after: first.id }) const second = await timeline.transport.waitForConnection({ after: first.id })
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) await timeline.transport.send(status("busy"))
await timeline.waitForPart("prt_transport_error")
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
expect(second.id).toBeGreaterThan(first.id) expect(second.id).toBeGreaterThan(first.id)
expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") expect((await timeline.transport.connections())[0]?.endedBy).toBe("error")
}) })
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { protocol: "v2" }) const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" })
const first = await timeline.transport.send( const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
partUpdated(textPart("prt_transport_id", "event with id")), id: "timeline-event-7",
{ id: "timeline-event-7" }, })
"/api/event",
)
await timeline.waitForPart("prt_transport_id") await timeline.waitForPart("prt_transport_id")
await timeline.transport.error("retry with event id", "/api/event") await timeline.transport.error("retry with event id")
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" }) const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
expect(first.eventID).toBe("timeline-event-7") expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined() expect(connection.headers["last-event-id"]).toBeUndefined()
@@ -122,35 +112,5 @@ test("passes through non-event fetches", async ({ page }) => {
}) })
expect(health).toEqual({ healthy: true }) expect(health).toEqual({ healthy: true })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) expect(await timeline.transport.connections()).toHaveLength(1)
}) })
async function stableTimelineRows(page: Page) {
let previous: Awaited<ReturnType<typeof timelineRows>> | undefined
let stable = 0
await expect
.poll(
async () => {
const next = await timelineRows(page)
stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0
previous = next
return stable
},
{ intervals: [50, 50, 100] },
)
.toBeGreaterThanOrEqual(2)
return previous!
}
function timelineRows(page: Page) {
return page.locator("[data-timeline-key]").evaluateAll((elements) =>
elements.map((element) => ({
key: element.getAttribute("data-timeline-key"),
row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"),
parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) =>
part.getAttribute("data-timeline-part-id"),
),
text: element.textContent,
})),
)
}
@@ -1,365 +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 an existing workspace from the 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"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
})
test("lists and manually deletes workspaces from settings", async ({ page }) => {
const draftID = "draft_workspace_settings"
const cleanWorkspace = `${workspace}-clean`
const inventory = { ...project, sandboxes: [cleanWorkspace] }
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project: inventory,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
let releaseSessions = () => {}
const sessionsReady = new Promise<void>((resolve) => {
releaseSessions = resolve
})
await page.route("**/session?**", async (route) => {
const url = new URL(route.request().url())
if (route.request().method() !== "GET" || url.pathname !== "/session" || url.searchParams.get("limit") !== "1000")
return route.fallback()
await sessionsReady
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()
await transport.send({
directory: "global",
payload: {
id: "evt_workspace_deleted_settings",
type: "project.updated",
properties: { ...inventory, sandboxes: [] },
},
})
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 expectAppVisible(page.locator('[data-component="prompt-input"]'))
const app = page.locator('[data-component="session-new-design"]')
const appNode = await app.elementHandle()
await page.getByRole("button", { name: /local|new workspace/i }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
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")
expect(await appNode?.evaluate((node) => node.isConnected)).toBe(true)
releaseSessions()
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
const confirmation = page
.locator('[data-component="dialog-v2"]')
.filter({ hasText: 'Delete workspace "feature-clean"?' })
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
)
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
const request = await removed
expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
})
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 },
}
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
}
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")
const sent = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
)
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_submit_ready",
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await sent
await expect(lifecycle).toContainText("Workspace created")
})
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 },
}
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
}
session.directory = createdWorkspace
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")
const moved = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
)
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_worktree_ready",
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
expect((await moved).postDataJSON()).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")
})
+21 -45
View File
@@ -29,38 +29,23 @@ export type SseEventOptions = {
export type SseTransport<T> = { export type SseTransport<T> = {
server: string server: string
waitForConnection(options?: { waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
after?: number send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
timeout?: number
path?: SseConnectionRecord["path"]
}): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]> burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement> split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement> heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement> writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
close(): Promise<void> close(): Promise<void>
disconnect(message?: string): Promise<void> disconnect(message?: string): Promise<void>
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void> error(message?: string): Promise<void>
connections(): Promise<SseConnectionRecord[]> connections(): Promise<SseConnectionRecord[]>
acknowledgements(): Promise<SseDeliveryAcknowledgement[]> acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
} }
type BrowserCommand<T> = type BrowserCommand<T> =
| { | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
type: "send"
deliveries: { payload: T; options?: SseEventOptions }[]
burst: boolean
cuts?: number[]
path?: SseConnectionRecord["path"]
}
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
| { | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
type: "end"
mode: "close" | "disconnect" | "error"
message?: string
path?: SseConnectionRecord["path"]
}
| { type: "connections" } | { type: "connections" }
| { type: "acknowledgements" } | { type: "acknowledgements" }
@@ -88,8 +73,7 @@ export async function installSseTransport<T>(
let nextConnectionID = 0 let nextConnectionID = 0
let nextDeliveryID = 0 let nextDeliveryID = 0
const current = (path?: SseConnectionRecord["path"]) => const current = () => connections.findLast((connection) => connection.endedAt === undefined)
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
const boundaries = [...new Set(cuts ?? [])] const boundaries = [...new Set(cuts ?? [])]
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
@@ -141,8 +125,8 @@ export async function installSseTransport<T>(
acknowledgements.push(acknowledgement) acknowledgements.push(acknowledgement)
return acknowledgement return acknowledgement
} }
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => { const end = (mode: "close" | "disconnect" | "error", message?: string) => {
const connection = current(path) const connection = current()
if (!connection) throw new Error("SSE transport has no active connection") if (!connection) throw new Error("SSE transport has no active connection")
connection.endedAt = performance.now() connection.endedAt = performance.now()
connection.endedBy = mode connection.endedBy = mode
@@ -162,8 +146,8 @@ export async function installSseTransport<T>(
if (input.type === "connections") if (input.type === "connections")
return connections.map(({ controller: _controller, ...connection }) => connection) return connections.map(({ controller: _controller, ...connection }) => connection)
if (input.type === "acknowledgements") return acknowledgements if (input.type === "acknowledgements") return acknowledgements
if (input.type === "end") return end(input.mode, input.message, input.path) if (input.type === "end") return end(input.mode, input.message)
const connection = current(input.type === "send" ? input.path : undefined) const connection = current()
if (!connection) throw new Error("SSE transport has no active connection") if (!connection) throw new Error("SSE transport has no active connection")
if (input.type === "raw") { if (input.type === "raw") {
marker(input.marker) marker(input.marker)
@@ -263,29 +247,21 @@ export async function installSseTransport<T>(
return { return {
server, server,
async waitForConnection(input = {}) { async waitForConnection(input = {}) {
const connection = await page.waitForFunction( await page.waitForFunction(
({ after, path }) => { (after) => {
const transport = (window as BrowserTransport).__testSseTransport const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.findLast( return connections?.some((connection) => connection.id > after)
(connection) =>
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
)
}, },
{ after: input.after ?? 0, path: input.path }, input.after ?? 0,
{ timeout: input.timeout }, { timeout: input.timeout },
) )
let result: SseConnectionRecord | undefined return (await command<SseConnectionRecord[]>({ type: "connections" })).findLast(
try { (connection) => connection.id > (input.after ?? 0),
result = await connection.jsonValue() )!
} finally {
await connection.dispose()
}
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
}, },
send(payload, eventOptions, path) { send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path }) return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
}, },
burst(payloads, eventOptions = []) { burst(payloads, eventOptions = []) {
return command({ return command({
@@ -323,8 +299,8 @@ export async function installSseTransport<T>(
disconnect(message) { disconnect(message) {
return command({ type: "end", mode: "disconnect", message }) return command({ type: "end", mode: "disconnect", message })
}, },
error(message, path) { error(message) {
return command({ type: "end", mode: "error", message, path }) return command({ type: "end", mode: "error", message })
}, },
connections() { connections() {
return command({ type: "connections" }) return command({ type: "connections" })
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.18.15", "version": "1.18.13",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -28,8 +28,7 @@
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report", "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: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", "test:bench": "bun test ./e2e/performance/unit && playwright test --config e2e/performance/playwright.config.ts"
"profile:desktop": "bun run e2e/performance/profile-desktop.ts"
}, },
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
+14 -14
View File
@@ -1,9 +1,9 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
@@ -58,18 +58,18 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context" import { WslServersProvider } from "@/wsl/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health" import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route" import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { createSessionLineage } from "@/pages/session/session-lineage" import { createSessionLineage } from "@/pages/session/session-lineage"
import { SessionPage, SessionRouteErrorBoundary, TargetSessionRoute } from "@/pages/session-lazy"
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 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 SessionRoute = () => { const SessionRoute = () => {
const settings = useSettings() const settings = useSettings()
@@ -127,6 +127,12 @@ function TargetServerRoute(props: ParentProps) {
) )
} }
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
function LegacyTargetSessionRoute() { function LegacyTargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>() const params = useParams<{ serverKey: string; id: string }>()
return ( return (
@@ -229,13 +235,7 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return ( return (
<I18nProvider <I18nProvider
value={{ value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
> >
{props.children} {props.children}
</I18nProvider> </I18nProvider>
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={6} gutter={6}
delay="intent" openDelay={0}
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }} contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
value={ value={
<ModelTooltip <ModelTooltip
@@ -460,7 +460,7 @@ function ModelSelectorPopoverV2View(props: {
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={6} gutter={6}
delay="intent" openDelay={0}
value={ value={
<ModelTooltip <ModelTooltip
model={item} model={item}
@@ -1,5 +1,4 @@
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -15,7 +14,6 @@ export type DialogGoUpsellProps = {
export function DialogUsageExceeded(props: DialogGoUpsellProps) { export function DialogUsageExceeded(props: DialogGoUpsellProps) {
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const runAction = () => { const runAction = () => {
@@ -34,7 +32,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3"> <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={dismiss}> <Button variant="ghost" size="large" onClick={dismiss}>
{language.t("dialog.usageExceeded.dontShowAgain")} Don't show again
</Button> </Button>
<Button variant="primary" size="large" onClick={runAction}> <Button variant="primary" size="large" onClick={runAction}>
{props.actionLabel} {props.actionLabel}
@@ -139,7 +139,6 @@ test("resolves directory autocomplete from the current browser root", async () =
directories.push(input.location?.directory ?? "") directories.push(input.location?.directory ?? "")
return Promise.resolve({ data: [] }) return Promise.resolve({ data: [] })
}, },
list: () => Promise.resolve({ data: [] }),
}, },
}, },
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"] } as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
@@ -153,67 +152,6 @@ test("resolves directory autocomplete from the current browser root", async () =
expect(directories).toEqual(["/repo", "/repo/src"]) expect(directories).toEqual(["/repo", "/repo/src"])
}) })
test("keeps indexed directory results for servers that support empty search", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [{ path: "projects/", type: "directory" }] }),
list: () => Promise.reject(new Error("listing should not run when search returns results")),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("")).toEqual(["/home/luke/projects"])
})
test("lists the default directory when empty search is unsupported", async () => {
const calls: string[] = []
const directories = Array.from({ length: 60 }, (_, index) => ({
path: `project-${index}/`,
type: "directory" as const,
}))
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: (input: { location?: { directory?: string } }) => {
calls.push(input.location?.directory ?? "")
return Promise.resolve({
data: [...directories, { path: "README.md", type: "file" }],
})
},
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
const results = await search("")
expect(results).toHaveLength(60)
expect(results.at(-1)).toBe("/home/luke/project-59")
expect(calls).toEqual(["/home/luke"])
})
test("matches the default directory listing when typed search is unsupported", async () => {
const sdk = {
api: {
file: {
find: () => Promise.resolve({ data: [] }),
list: () =>
Promise.resolve({
data: [
{ path: "Documents/", type: "directory" },
{ path: "Downloads/", type: "directory" },
],
}),
},
},
} as unknown as Parameters<typeof createDirectorySearch>[0]["sdk"]
const search = createDirectorySearch({ sdk, home: () => "/home/luke", base: () => "/home/luke" })
expect(await search("documents")).toEqual(["/home/luke/Documents"])
})
test("searches from an absolute root without a default base", async () => { test("searches from an absolute root without a default base", async () => {
const directories: string[] = [] const directories: string[] = []
const sdk = { const sdk = {
@@ -379,14 +379,7 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string
.then((result) => result.data.map((entry) => entry.path)) .then((result) => result.data.map((entry) => entry.path))
.catch(() => []) .catch(() => [])
if (!active()) return [] if (!active()) return []
if (results.length) { return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50)
}
const fallback = query
? await match(input.directory, query, 50)
: (await directories(input.directory)).map((item) => item.absolute)
if (!active()) return []
return fallback
} }
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
const head = segments.slice(0, -1).filter((part) => part && part !== ".") const head = segments.slice(0, -1).filter((part) => part && part !== ".")
@@ -10,6 +10,9 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import homeImage from "@/assets/help/home.png" import homeImage from "@/assets/help/home.png"
import tabsImage from "@/assets/help/tabs.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 // can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() { export function TabsInfoPopup() {
const settings = useSettings() const settings = useSettings()
@@ -131,6 +134,7 @@ export function TabsInfoPopup() {
<p>{language.t("help.tabs.home")}</p> <p>{language.t("help.tabs.home")}</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" /> <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.persistence")}</p>
<p>{language.t("help.tabs.worktrees")}</p>
</div> </div>
</div> </div>
</DrawerContent> </DrawerContent>
@@ -1,236 +0,0 @@
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSync } from "@/context/sync"
import { ExternalLink } from "./external-link"
type SkillItem = {
name: string
location: string
}
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
const ExtensionCard: Component<{ children: unknown }> = (props) => (
<div class="project-settings-extension-card">{props.children as any}</div>
)
const ExtensionRow: Component<{
icon: "mcp" | "code" | "brain"
name: string
status?: string
children?: unknown
}> = (props) => (
<div class="project-settings-extension-row">
<div class="project-settings-extension-row-main">
<Icon name={props.icon} class="project-settings-extension-row-icon" />
<span class="project-settings-extension-row-name">{props.name}</span>
</div>
<Show when={props.status}>
{(status) => (
<span class="project-settings-extension-row-status">
<span class="project-settings-extension-row-status-dot" />
{status()}
</span>
)}
</Show>
{props.children as any}
</div>
)
const SharedSection: Component<{
count: number
children: unknown
}> = (props) => {
const language = useLanguage()
const [open, setOpen] = createSignal(false)
return (
<Show when={props.count > 0}>
<div class="project-settings-shared">
<button
type="button"
class="project-settings-shared-trigger"
aria-expanded={open()}
onClick={() => setOpen((value) => !value)}
>
<Icon name="chevron-right" classList={{ "project-settings-shared-chevron": true, open: open() }} />
<span>{language.t("project.settings.extensions.shared")}</span>
<span class="project-settings-shared-count">{props.count}</span>
</button>
<Show when={open()}>
<ExtensionCard>{props.children}</ExtensionCard>
</Show>
</div>
</Show>
)
}
export const ProjectSettingsExtensions: Component = () => {
const language = useLanguage()
const serverSDK = useServerSDK()
const directorySDK = useSDK()
const serverSync = useServerSync()
const sync = useSync()
const toggleMcp = useMcpToggle()
const [serverMcp] = createResource(
serverSDK,
(sdk) =>
sdk.api.mcp
.list()
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
.catch(() => ({})),
{ initialValue: {} },
)
const globalMcpNames = createMemo(() =>
[...new Set([...Object.keys(serverSync().data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
)
const projectMcpNames = createMemo(() => {
const shared = new Set(globalMcpNames())
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
if (configured.length > 0) return configured.sort()
return Object.keys(sync().data.mcp ?? {})
.filter((name) => !shared.has(name))
.sort()
})
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
const globalPlugins = createMemo(() => (serverSync().data.config.plugin ?? []).map(pluginName))
const projectPlugins = createMemo(() => {
const shared = new Set(globalPlugins())
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
})
const [serverSkills] = createResource(
serverSDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const [directorySkills] = createResource(
directorySDK,
(sdk): Promise<SkillItem[]> =>
sdk.api.skill
.list({ location: { directory: sdk.directory } })
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
{ initialValue: [] },
)
const projectSkills = createMemo(() => {
const shared = new Set(serverSkills.latest.map(skillKey))
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
})
const mcpRows = (items: string[]) => (
<For each={items}>
{(name) => (
<ExtensionRow icon="mcp" name={name}>
<Switch
checked={mcpEnabled(name)}
disabled={toggleMcp.isPending && toggleMcp.variables === name}
hideLabel
onChange={() => {
if (toggleMcp.isPending) return
toggleMcp.mutate(name)
}}
>
{name}
</Switch>
</ExtensionRow>
)}
</For>
)
const pluginRows = (items: string[]) => <For each={items}>{(name) => <ExtensionRow icon="code" name={name} />}</For>
const skillRows = (items: SkillItem[]) => <For each={items}>{(item) => <ExtensionRow icon="brain" name={item.name} />}</For>
return (
<div class="project-settings-extensions">
<div class="project-settings-page-header">
<h2>{language.t("settings.tab.extensions")}</h2>
<span>{language.t("project.settings.extensions.description")}</span>
</div>
<TabsV2 variant="pill" defaultValue="mcps" class="project-settings-extension-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectMcpNames().length > 0}>
<ExtensionCard>{mcpRows(projectMcpNames())}</ExtensionCard>
</Show>
<SharedSection count={globalMcpNames().length}>{mcpRows(globalMcpNames())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<span>{language.t("settings.extensions.manageConfig")}</span>
</div>
<Show when={projectPlugins().length > 0}>
<ExtensionCard>{pluginRows(projectPlugins())}</ExtensionCard>
</Show>
<SharedSection count={globalPlugins().length}>{pluginRows(globalPlugins())}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.added")}</span>
<ExternalLink class="project-settings-extension-link" href="https://opencode.ai/docs/skills/">
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<Show when={projectSkills().length > 0}>
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
</Show>
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
</div>
</TabsV2.Content>
<TabsV2.Content value="lsps">
<div class="project-settings-extension-section">
<div class="project-settings-extension-section-header">
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
<span>{language.t("project.settings.extensions.lsp.description")}</span>
</div>
<Show when={sync().data.lsp.length > 0}>
<ExtensionCard>
<For each={sync().data.lsp}>
{(item) => (
<ExtensionRow
icon="code"
name={item.name || item.id}
status={
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
}
/>
)}
</For>
</ExtensionCard>
</Show>
</div>
</TabsV2.Content>
</TabsV2>
</div>
)
}
@@ -37,7 +37,6 @@ export type PromptInputV2ComposerProps = {
class?: string class?: string
controller: PromptInputV2ComposerController controller: PromptInputV2ComposerController
borderUnderlay?: boolean borderUnderlay?: boolean
accentSubmit?: boolean
} }
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission"> export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -54,7 +53,6 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<PromptInputV2 <PromptInputV2
controller={props.controller} controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay} borderUnderlay={props.borderUnderlay}
class={props.class} class={props.class}
variantControlVisible={!props.controller.model.loading} variantControlVisible={!props.controller.model.loading}
@@ -47,7 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
</span> </span>
} }
placement="top" placement="top"
{...(!props.newLayoutDesigns ? { openDelay: 800 } : {})} openDelay={800}
> >
<div <div
classList={{ classList={{
@@ -53,7 +53,12 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<For each={props.comments ?? []}> <For each={props.comments ?? []}>
{(item) => ( {(item) => (
<div class="relative group shrink-0"> <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 <CommentCardV2
comment={item.comment ?? ""} comment={item.comment ?? ""}
path={item.path} path={item.path}
@@ -1,18 +1,18 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt" import type { Prompt, PromptStore } from "@/context/prompt"
import { Worktree } from "@/utils/worktree" import type { ModelSelection } from "@/context/local"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = [] const createdSessions: string[] = []
type SessionCreateInput = { const sessionCreateInputs: Array<{
agent?: string agent?: string
model?: { id: string; providerID: string; variant?: string } model?: { id: string; providerID: string; variant?: string }
location?: { directory: string } location?: { directory: string }
} }> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
const optimistic: Array<{ const optimistic: Array<{
directory?: string directory?: string
sessionID?: string sessionID?: string
@@ -22,35 +22,24 @@ const optimistic: Array<{
variant?: string variant?: string
} }
}> = [] }> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {} 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 sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const sentShellDirectories: string[] = [] const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = [] const sentPrompts: string[] = []
const promptInputs: unknown[] = [] const promptInputs: unknown[] = []
const sentCommands: unknown[] = [] const sentCommands: unknown[] = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] const commands: Array<{ name: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0 let serverSessionSyncs = 0
let params: { id?: string } = {} let params: { id?: string } = {}
let search: { draftId?: string } = {} let search: { draftId?: string } = {}
let selected = "/repo/worktree-a" let selected = "/repo/worktree-a"
let variant: string | undefined let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined let createSessionGate: Promise<void> | 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 }] let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({ const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -78,21 +67,19 @@ const prompt = {
replaceComments: () => undefined, replaceComments: () => undefined,
items: () => [], items: () => [],
}, },
capture: (scope?: unknown, target?: unknown) => { capture: () => prompt,
promptCaptures.push({ scope, target })
return prompt
},
} }
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => { const clientFor = (directory: string) => {
createdClients.push(directory)
return { return {
api: { api: {
session: { session: {
create: async (input: SessionCreateInput) => { create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate await createSessionGate
const location = input.location?.directory ?? directory const location = input.location?.directory ?? directory
createdSessions.push(location) createdSessions.push(location)
sessionCreateInputs.push(input)
return { return {
id: `session-${createdSessions.length}`, id: `session-${createdSessions.length}`,
projectID: "project", projectID: "project",
@@ -115,7 +102,6 @@ const clientFor = (directory: string) => {
}, },
shell: async (input: { sessionID: string; id?: string; command: string }) => { shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input) sentShell.push(input)
sentShellDirectories.push(directory)
}, },
}, },
}, },
@@ -124,16 +110,7 @@ const clientFor = (directory: string) => {
abort: async () => ({ data: undefined }), abort: async () => ({ data: undefined }),
}, },
worktree: { worktree: {
create: async (_input: unknown, options?: { signal?: AbortSignal }) => { create: async () => ({ data: { directory: `${directory}/new` } }),
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 } }
},
}, },
} }
} }
@@ -150,13 +127,13 @@ beforeAll(async () => {
mock.module("@opencode-ai/sdk/v2/client", () => ({ mock.module("@opencode-ai/sdk/v2/client", () => ({
createOpencodeClient: (input: { directory: string }) => { createOpencodeClient: (input: { directory: string }) => {
createdClients.push(input.directory)
return clientFor(input.directory) return clientFor(input.directory)
}, },
})) }))
mock.module("@opencode-ai/ui/toast", () => ({ mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null }, Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0, showToast: () => 0,
})) }))
@@ -174,13 +151,20 @@ beforeAll(async () => {
current: () => ({ name: "agent" }), current: () => ({ name: "agent" }),
}, },
session: { session: {
promote: () => undefined, promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
}, },
}), }),
})) }))
mock.module("@/context/permission", () => { mock.module("@/context/permission", () => {
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
}) })
mock.module("@/context/server", () => ({ mock.module("@/context/server", () => ({
@@ -189,10 +173,7 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({ mock.module("@/context/tabs", () => ({
useTabs: () => ({ useTabs: () => ({
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }), draft: () => ({ server: "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session }) promotedDrafts.push({ draftID, ...session })
}, },
@@ -213,75 +194,72 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({ mock.module("@/context/sdk", () => ({
useSDK: () => { useSDK: () => {
return () => ({ const sdk = {
scope: activeSDK === "server-a" ? ServerScope.local : "server-b", scope: "local",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", directory: "/repo/main",
client: rootClient, client: rootClient,
api: rootClient.api, api: rootClient.api,
url: "http://localhost:4096", url: "http://localhost:4096",
createApi: (directory: string) => clientFor(directory).api,
createClient(opts: any) { createClient(opts: any) {
return clientFor(opts.directory) return clientFor(opts.directory)
}, },
}) }
return () => sdk
}, },
})) }))
mock.module("@/context/sync", () => ({ mock.module("@/context/sync", () => ({
useSync: () => () => { useSync: () => () => ({
const server = activeDirectorySync data: { command: commands },
return { session: {
data: { command: commands }, optimistic: {
session: { add: (value: {
optimistic: { directory?: string
add: (value: { sessionID?: string
directory?: string message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
sessionID?: string }) => {
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } optimistic.push(value)
}) => { optimisticSeeded.push(
optimisticServers.push(server) !!value.directory &&
optimistic.push(value) !!value.sessionID &&
}, !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
remove: () => undefined, )
}, },
remove: () => undefined,
}, },
set: () => undefined, },
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" }, set: () => undefined,
} }),
},
})) }))
mock.module("@/context/server-sync", () => ({ mock.module("@/context/server-sync", () => ({
useServerSync: () => () => { useServerSync: () => () => ({
const server = activeServerSync session: {
return { remember: () => undefined,
session: { set: () => undefined,
remember: () => undefined, sync: async () => {
set: () => undefined, serverSessionSyncs++
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) }),
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", () => ({ mock.module("@/context/platform", () => ({
@@ -301,153 +279,198 @@ beforeAll(async () => {
}) })
beforeEach(() => { beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0 createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0 optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0 promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0 sentPrompts.length = 0
promptInputs.length = 0 promptInputs.length = 0
syncedServers.length = 0 sentCommands.length = 0
optimisticServers.length = 0 commands.length = 0
promptCaptures.length = 0 promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {} params = {}
search = {} search = {}
sentShell.length = 0 sentShell.length = 0
sentShellDirectories.length = 0 syncedDirectories.length = 0
selected = "/repo/worktree-a" selected = "/repo/worktree-a"
variant = undefined variant = undefined
activeSDK = "server-a" permissionServer = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined createSessionGate = undefined
serverSessionSyncs = 0 serverSessionSyncs = 0
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] for (const key of Object.keys(storedSessions)) delete storedSessions[key]
}) })
const event = { preventDefault: () => undefined } as unknown as Event
const makeSubmit = (overrides: Partial<Parameters<typeof createPromptSubmit>[0]> = {}) =>
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,
...overrides,
})
describe("prompt submit worktree selection", () => { describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => { test("reads the latest worktree accessor value per submit", async () => {
selected = "create" const submit = createPromptSubmit({
let release = () => {} prompt,
createWorktreeGate = new Promise<void>((resolve) => { info: () => undefined,
release = resolve 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,
}) })
const submit = makeSubmit()
const first = submit.handleSubmit(event) const event = { preventDefault: () => undefined } as unknown as Event
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
release() await submit.handleSubmit(event)
await Promise.all([first, duplicate]) selected = "/repo/worktree-b"
expect(createdSessions).toEqual([worktreeDirectory]) await submit.handleSubmit(event)
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(1) expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(createdSessions).toHaveLength(1) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentPrompts).toEqual([worktreeDirectory]) expect(sessionCreateInputs).toEqual([
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-a" },
},
{
agent: "agent",
model: { id: "model", providerID: "provider", variant: undefined },
location: { directory: "/repo/worktree-b" },
},
])
expect(sentShell).toEqual([
expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }),
expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }),
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
expect(serverSessionSyncs).toBe(0)
expect(promoted).toEqual([
{ directory: "/repo/worktree-a", sessionID: "session-1" },
{ directory: "/repo/worktree-b", sessionID: "session-2" },
])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
}) })
test("aborts a hung new-workspace request and allows retry", async () => { test("applies auto-accept to newly created sessions", async () => {
selected = "create" const submit = createPromptSubmit({
worktreeHung = true prompt,
let resets = 0 info: () => undefined,
const submit = makeSubmit({ imageAttachments: () => [],
onNewSessionWorktreeReset: () => resets++, commentCount: () => 0,
worktreeRequestTimeoutMs: 1, autoAccept: () => true,
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,
}) })
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
expect(worktreeCreates).toBe(1) expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
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("keeps async submission effects bound to the initiating context", async () => { test("keeps auto-accept bound to the submission server", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {} let release = () => {}
createSessionGate = new Promise<void>((resolve) => { createSessionGate = new Promise<void>((resolve) => {
release = resolve release = resolve
}) })
let submitted = 0 const submit = createPromptSubmit({
const submit = makeSubmit({ prompt,
onSubmit: () => submitted++, info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
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,
}) })
const result = submit.handleSubmit(event) const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
activeSDK = "server-b" permissionServer = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release() release()
await result await result
await settle()
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
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"]) test("promotes drafts using the selected project's server", async () => {
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) search = { draftId: "draft-1" }
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete") const submit = createPromptSubmit({
expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined() prompt,
expect(submitted).toBe(0) 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(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
}) })
test("includes the selected variant on optimistic prompts", async () => { test("includes the selected variant on optimistic prompts", async () => {
params.id = "session-1" params = { id: "session-1" }
variant = "high" variant = "high"
const submit = makeSubmit({ const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
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,
onSubmit: () => undefined,
}) })
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event) await submit.handleSubmit(event)
await Bun.sleep(0) await Bun.sleep(0)
@@ -477,12 +500,24 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" }) commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = makeSubmit({ const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }), info: () => ({ id: "session-1" }),
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,
}) })
await submit.handleSubmit(event) await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
await settle()
expect(sentCommands).toEqual([ expect(sentCommands).toEqual([
{ {
@@ -498,43 +533,66 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0) expect(serverSessionSyncs).toBe(0)
}) })
test("waits for a new workspace before sending an initial shell", async () => { test("uses an injected model selection", async () => {
selected = "create" params = { id: "session-1" }
const submit = makeSubmit({ const model = {
mode: () => "shell", current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
variant: { current: () => "draft-variant" },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
info: () => ({ id: "session-1" }),
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,
model,
}) })
await submit.handleSubmit(event) 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) expect(optimistic[0]).toMatchObject({
await settle() message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
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("settles a pending workspace operation when the initial prompt is aborted", async () => { test("seeds new sessions before optimistic prompts are added", async () => {
selected = "create" const submit = createPromptSubmit({
const submit = makeSubmit() 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) await submit.handleSubmit(event)
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
params = { id: "session-1" }
await submit.abort() expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
await settle() expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
expect(sentPrompts).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
status: "failed",
})
}) })
}) })
+266 -365
View File
@@ -15,12 +15,10 @@ import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync" import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id" import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree" 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 { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom" import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors" 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 { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event" import { Event } from "@opencode-ai/schema/event"
@@ -29,13 +27,9 @@ import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = { type PendingPrompt = {
abort: AbortController abort: AbortController
cleanup: VoidFunction cleanup: VoidFunction
scope: ServerScope
sessionID: string
serverSync: ServerSync
} }
const pending = new Map<string, PendingPrompt>() const pending = new Map<string, PendingPrompt>()
const submitting = new Set<string>()
export type FollowupDraft = { export type FollowupDraft = {
sessionID: string sessionID: string
@@ -49,7 +43,6 @@ export type FollowupDraft = {
type FollowupSendInput = { type FollowupSendInput = {
api: DirectorySDK["api"]["session"] api: DirectorySDK["api"]["session"]
scope: ServerScope
serverSync: ServerSync serverSync: ServerSync
sync: DirectorySync sync: DirectorySync
draft: FollowupDraft 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") const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
export async function sendFollowupDraft(input: FollowupSendInput) { 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 text = draftText(input.draft.prompt)
const images = draftImages(input.draft.prompt) const images = draftImages(input.draft.prompt)
const setBusy = () => { const setBusy = () => {
@@ -238,7 +229,6 @@ type PromptSubmitInput = {
onAbort?: () => void onAbort?: () => void
onSubmit?: () => void onSubmit?: () => void
model?: ModelSelection model?: ModelSelection
worktreeRequestTimeoutMs?: number
} }
export function createPromptSubmit(input: PromptSubmitInput) { export function createPromptSubmit(input: PromptSubmitInput) {
@@ -254,8 +244,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams() const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>() const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs() const tabs = useTabs()
const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID) const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined
const errorMessage = (err: unknown) => { const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message 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 abort = async () => {
const routeSessionID = params.id const sessionID = params.id
const owned =
pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID)
? pending.get(pendingSubmission.key)
: undefined
const sessionID = routeSessionID ?? owned?.sessionID
if (!sessionID) return Promise.resolve() if (!sessionID) return Promise.resolve()
;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, [])
serverSync().session.set("todo", sessionID, [])
input.onAbort?.() input.onAbort?.()
const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID) const key = pendingKey(sessionID)
const queued = owned ?? pending.get(key) const queued = pending.get(key)
if (queued) { if (queued) {
queued.abort.abort() queued.abort.abort()
queued.cleanup() queued.cleanup()
WorkspaceOperation.fail(queued.scope, queued.sessionID)
pending.delete(key) pending.delete(key)
return Promise.resolve() return Promise.resolve()
} }
@@ -316,9 +300,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
} }
} }
const seed = (target: ServerSync, dir: string, info: Session) => { const seed = (dir: string, info: Session) => {
target.session.remember(info) serverSync().session.remember(info)
const [, setStore] = target.child(dir) const [, setStore] = serverSync().child(dir)
setStore("session", (list: Session[]) => { setStore("session", (list: Session[]) => {
const result = Binary.search(list, info.id, (item) => item.id) const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list] const next = [...list]
@@ -350,7 +334,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort() if (input.working()) void abort()
return return
} }
if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return
const modelSelection = input.model ?? local.model const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current() const currentModel = modelSelection.current()
@@ -364,377 +347,295 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return return
} }
const submissionSDK = sdk() input.addToHistory(currentPrompt, mode)
const submissionSync = sync() input.resetHistoryNavigation()
const submissionServerSync = serverSync()
const submissionScope = submissionSDK.scope const projectDirectory = sdk().directory
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
const permissionState = permission.currentServerState() const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept() const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main" 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 { let sessionDirectory = projectDirectory
input.addToHistory(currentPrompt, mode) let client = sdk().client
input.resetHistoryNavigation()
let sessionDirectory = projectDirectory if (isNewSession) {
let client = submissionSDK.client if (worktreeSelection === "create") {
let api = submissionSDK.api.session const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
if (isNewSession) { .then((x) => x.data)
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)
.catch((err) => { .catch((err) => {
showToast({ showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"), title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err), description: errorMessage(err),
}) })
return undefined return undefined
}) })
if (created) {
seed(submissionServerSync, sessionDirectory, created) if (!createdWorktree?.directory) {
session = created showToast({
await startTransition(() => { title: language.t("prompt.toast.worktreeCreateFailed.title"),
if (!session) return description: language.t("common.requestFailed"),
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 (!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)
}
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)
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)
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 return
} }
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
} }
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) if (worktreeSelection !== "main" && worktreeSelection !== "create") {
const messageID = Identifier.ascending("message") sessionDirectory = worktreeSelection
startWorkspaceOperation(messageID) }
const removeOptimisticMessage = () => { if (sessionDirectory !== projectDirectory) {
submissionSync.session.optimistic.remove({ client = sdk().createClient({
directory: sessionDirectory, directory: sessionDirectory,
sessionID: session.id, throwOnError: true,
messageID, })
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() 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 = () => { const cleanup = () => {
if (sessionDirectory === projectDirectory) { if (sessionDirectory === projectDirectory) {
submissionSync.set("session_status", session.id, { type: "idle" }) sync().set("session_status", session.id, { type: "idle" })
} }
removeOptimisticMessage() removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
} }
void sendFollowupDraft({ pending.set(pendingKey(session.id), { abort: controller, cleanup })
api,
scope: submissionScope, const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
sync: submissionSync, if (controller.signal.aborted) {
serverSync: submissionServerSync, resolve({ status: "failed", message: "aborted" })
draft, return
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" })
} }
showToast({ controller.signal.addEventListener(
title: language.t("prompt.toast.promptSendFailed.title"), "abort",
description: errorMessage(err), () => {
}) resolve({ status: "failed", message: "aborted" })
removeOptimisticMessage() },
if (restoreInput()) restoreCommentItems(submission.target(), commentItems) { 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 { 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 { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-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 { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -10,42 +11,25 @@ export function PromptWorkspaceSelector(props: {
projectRoot: string projectRoot: string
workspaces: string[] workspaces: string[]
branch?: string branch?: string
onboarding?: boolean
onChange: (value: string) => void onChange: (value: string) => void
onDone: () => void onDone: () => void
onViewAll: () => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [search, setSearch] = createSignal("") let pending: string | undefined
let searchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
const selected = () => (props.value === props.projectRoot ? "main" : props.value) 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 = () => { const icon = () => {
if (selected() === "main") return "monitor" if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new" if (selected() === "create") return "workspace-new"
return "workspace-isolated" return "workspace"
} }
const select = (value: string) => { const select = (value: string) => {
pending = { type: "select", value } pending = value
} }
const onOpenChange = (open: boolean) => { const onOpenChange = (open: boolean) => {
if (open) { if (open) return
setSearch("") const value = pending
return
}
const action = pending
pending = undefined pending = undefined
if (action?.type === "select") props.onChange(action.value) if (value) props.onChange(value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone() props.onDone()
} }
const label = () => { const label = () => {
@@ -57,217 +41,87 @@ export function PromptWorkspaceSelector(props: {
return ( return (
<> <>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span> <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2 <MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
placement="top" <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">
value={ <IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
props.onboarding ? ( <span class="min-w-0 truncate">{label()}</span>
<div class="flex flex-col gap-1 text-left"> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base"> </MenuV2.Trigger>
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" /> <MenuV2.Portal>
<span>{language.t("workspace.onboarding.title")}</span> <MenuV2.Content class="w-[180px]">
</div> <MenuV2.Group>
<span class="font-[440] text-v2-text-text-muted"> <MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
{language.t("workspace.onboarding.description")} <MenuV2.Item onSelect={() => select("main")}>
</span> <IconV2 name="monitor" />
</div> <span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
) : ( <Show when={selected() === "main"}>
language.t("session.new.workspace.trigger.tooltip") <Icon name="check" size="small" class="shrink-0" />
) </Show>
} </MenuV2.Item>
contentClass={props.onboarding ? "max-w-[280px]" : undefined} <MenuV2.Item onSelect={() => select("create")}>
class="min-w-0" <IconV2 name="workspace-new" />
> <span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}> <Show when={selected() === "create"}>
<MenuV2.Trigger <Icon name="check" size="small" class="shrink-0" />
aria-description={language.t("session.new.workspace.trigger.tooltip")} </Show>
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" </MenuV2.Item>
> </MenuV2.Group>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" /> <Show when={props.workspaces.length > 0}>
<span class="min-w-0 truncate">{label()}</span> <MenuV2.Separator />
<Show when={props.onboarding}> <MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<span <MenuV2.SubTrigger>
data-slot="workspace-onboarding-dot" <IconV2 name="workspace" />
aria-hidden="true" {language.t("session.new.workspace.existing")}
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent" </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> </Show>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" /> </MenuV2.Content>
</MenuV2.Trigger> </MenuV2.Portal>
<MenuV2.Portal> </MenuV2>
<MenuV2.Content class="w-[200px]"> <PromptGitStatus branch={props.branch} />
<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}
fallback={
<>
<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.Item>
</>
}
>
<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(/(…|\.{3})$/, "")}
</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.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.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
</> </>
) )
} }
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) { export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage() const language = useLanguage()
const label = () => { const label = () => {
if (props.noGit) return language.t("session.new.git.none") 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 return props.branch
} }
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return ( return (
<Show when={label()}> <Show when={label()}>
{(value) => ( {(value) => (
<TooltipV2 <>
placement="top" <span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
value={value()} <TooltipV2
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`} placement="top"
contentClass="max-w-[calc(100vw-32px)] break-all" value={value()}
> class="min-w-0 max-w-[220px]"
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint"> contentClass="max-w-[calc(100vw-32px)] break-all"
<Icon >
name={icon()} <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]">
size="small" <Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
class="shrink-0 text-v2-icon-icon-muted" <span class="min-w-0 truncate">{value()}</span>
/> </div>
<span class="min-w-0 truncate">{value()}</span> </TooltipV2>
</div> </>
</TooltipV2>
)} )}
</Show> </Show>
) )
@@ -1,196 +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 { useSettingsDialog } from "@/components/settings-dialog"
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
contentClass?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
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) => {
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
WorkspaceOperation.fail(scope, sessionID)
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)
try {
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)) throw new Error(language.t("workspace.move.failed"))
await workspaceRequestWithTimeout(
(signal) =>
sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory: destination },
moveChanges: true,
},
{ signal },
),
language.t("workspace.move.failed"),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
const session = await workspaceRequestWithTimeout(
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
language.t("workspace.move.failed"),
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
)
if (!session || pathKey(session.directory) !== pathKey(destination))
throw new Error(language.t("workspace.move.failed"))
WorkspaceOperation.complete(scope, sessionID, destination)
sync.reindexSession(sessionID, source)
} catch (error) {
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
} finally {
setStore("selected", undefined)
}
}
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] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<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.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</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
}
@@ -5,15 +5,12 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { findLast } from "@opencode-ai/core/util/array" import { findLast } from "@opencode-ai/core/util/array"
import { same } from "@/utils/same" import { same } from "@/utils/same"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Accordion } from "@opencode-ai/ui/accordion" import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { File } from "@opencode-ai/session-ui/file" import { File } from "@opencode-ai/session-ui/file"
import { Markdown } from "@opencode-ai/session-ui/markdown" import { Markdown } from "@opencode-ai/session-ui/markdown"
import { ScrollView } from "@opencode-ai/ui/scroll-view" import { ScrollView } from "@opencode-ai/ui/scroll-view"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
@@ -123,8 +120,7 @@ export function SessionContextTab() {
() => { () => {
const revert = info()?.revert?.messageID const revert = info()?.revert?.messageID
if (!revert) return userMessages() if (!revert) return userMessages()
const boundary = userMessages().findIndex((message) => message.id === revert) return userMessages().filter((m) => m.id < revert)
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
}, },
emptyUserMessages, emptyUserMessages,
{ equals: same }, { equals: same },
@@ -224,31 +220,6 @@ export function SessionContextTab() {
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, { label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[] ] satisfies { label: string; value: () => JSX.Element }[]
const exportSession = async () => {
const sessionID = params.id
if (!sessionID) return
try {
const data = await fetchSessionExport({
sessionID,
client: sdk().client,
})
const filename = sessionExportFilename(data.info)
downloadSessionExport(filename, data)
showToast({
variant: "success",
icon: "circle-check",
title: language.t("toast.session.export.success.title"),
description: language.t("toast.session.export.success.description", { filename }),
})
} catch (err) {
showToast({
variant: "error",
title: language.t("toast.session.export.failed.title"),
description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"),
})
}
}
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
@@ -357,18 +328,7 @@ export function SessionContextTab() {
</Show> </Show>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Button
size="small"
variant="ghost"
class="gap-1.5 px-2 text-text-weak hover:text-text-base"
onClick={exportSession}
>
<Icon name="download" size="small" />
<span>{language.t("context.export.session")}</span>
</Button>
</div>
<Accordion multiple> <Accordion multiple>
<For each={messages()}> <For each={messages()}>
{(message) => ( {(message) => (
@@ -7,7 +7,6 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { type Component, For, Show } from "solid-js" import { type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSync } from "@/context/server-sync"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker" import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
@@ -44,7 +43,6 @@ export const SettingsModels: Component = () => {
const SettingsModelsContent: Component = () => { const SettingsModelsContent: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
useServerSync()().loadProviders()
const list = useFilteredList<ModelItem>({ const list = useFilteredList<ModelItem>({
items: (_filter) => models.list(), items: (_filter) => models.list(),
@@ -1,134 +0,0 @@
import { Component, createMemo } from "solid-js"
import { SelectV2 } from "@opencode-ai/ui/v2/select-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
import { createAppearanceSettingsController, type AppearanceSettingsController } from "./general-controllers"
import "./settings-v2.css"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
ui: {
action: "settings-ui-font",
title: "settings.general.row.uiFont.title",
description: "settings.general.row.uiFont.description",
font: "ui",
input: "setUI",
},
code: {
action: "settings-code-font",
title: "settings.general.row.font.title",
description: "settings.general.row.font.description",
font: "code",
input: "setCode",
},
terminal: {
action: "settings-terminal-font",
title: "settings.general.row.terminalFont.title",
description: "settings.general.row.terminalFont.description",
font: "terminal",
input: "setTerminal",
},
} as const
const FontSetting: Component<{
kind: "ui" | "code" | "terminal"
fonts: AppearanceSettingsController["fonts"]
}> = (props) => {
const language = useLanguage()
const config = () => fontSettings[props.kind]
return (
<SettingsRowV2 title={language.t(config().title)} description={language.t(config().description)}>
<div class="w-full sm:w-[220px]">
<TextInputV2
data-action={config().action}
type="text"
appearance="base"
value={props.fonts[config().font]().value}
onInput={(event) => props.fonts[config().input](event.currentTarget.value)}
placeholder={props.fonts[config().font]().placeholder}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
aria-label={language.t(config().title)}
style={{ "font-family": props.fonts[config().font]().family }}
/>
</div>
</SettingsRowV2>
)
}
export const SettingsAppearanceV2: Component = () => {
const language = useLanguage()
const appearance = createAppearanceSettingsController()
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.general.section.appearance")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.appearance.description")}</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<div class="settings-v2-section">
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.colorScheme.title")}
description={language.t("settings.general.row.colorScheme.description")}
>
<SelectV2
appearance="inline"
data-action="settings-color-scheme"
options={schemeOptions}
current={schemeOptions.find((option) => option === appearance.scheme.current())}
placement="bottom-end"
gutter={6}
label={(option) => {
if (option === "system") return language.t("theme.scheme.system")
if (option === "light") return language.t("theme.scheme.light")
return language.t("theme.scheme.dark")
}}
onSelect={(option) => option && appearance.scheme.select(option)}
/>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<ExternalLink class="settings-v2-link" href="https://opencode.ai/docs/themes/">
{language.t("common.learnMore")}
</ExternalLink>
</>
}
>
<SelectV2
appearance="inline"
data-action="settings-theme"
options={appearance.theme.options()}
current={appearance.theme.current()}
placement="bottom-end"
gutter={6}
value={(option) => option.id}
label={(option) => option.name}
onSelect={appearance.theme.select}
/>
</SettingsRowV2>
<FontSetting kind="ui" fonts={appearance.fonts} />
<FontSetting kind="code" fonts={appearance.fonts} />
<FontSetting kind="terminal" fonts={appearance.fonts} />
</SettingsListV2>
</div>
</div>
</>
)
}
@@ -10,8 +10,8 @@ import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models" import { SettingsModelsV2 } from "./models"
import "./settings-v2.css" import "./settings-v2.css"
import { SettingsServersV2 } from "./servers" import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs" import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
@@ -22,16 +22,18 @@ export const DialogSettings: Component<{
const language = useLanguage() const language = useLanguage()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog() const dialog = useDialog()
const layout = useLayout()
const tabs = useTabs() const tabs = useTabs()
const serverSync = useServerSync() const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general") const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const directory = createMemo(() => { const directory = createMemo(() => {
const active = tabs.active() const route = layout.route()
if (active?.type === "draft") return active.directory if (route.type === "dir-new-sesssion") return route.dir
if (active?.type === "session") { if (route.type === "draft") {
const session = serverSync().session.get(active.sessionId) const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
if (session) return session.directory return draft?.type === "draft" ? draft.directory : undefined
} }
if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory
return undefined return undefined
}) })
@@ -56,11 +58,11 @@ export const DialogSettings: Component<{
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle> <TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full"> <div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general"> <TabsV2.Trigger value="general">
<Icon name="sliders" size="small" /> <Icon name="sliders" />
{language.t("settings.tab.general")} {language.t("settings.tab.general")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts"> <TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" size="small" /> <Icon name="keyboard" />
{language.t("settings.tab.shortcuts")} {language.t("settings.tab.shortcuts")}
</TabsV2.Trigger> </TabsV2.Trigger>
</div> </div>
@@ -69,20 +71,16 @@ export const DialogSettings: Component<{
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle> <TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full"> <div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="workspaces">
<Icon name="workspace-isolated" size="small" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers"> <TabsV2.Trigger value="servers">
<Icon name="server" size="small" /> <Icon name="server" />
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="providers"> <TabsV2.Trigger value="providers">
<Icon name="providers" size="small" /> <Icon name="providers" />
{language.t("settings.providers.title")} {language.t("settings.providers.title")}
</TabsV2.Trigger> </TabsV2.Trigger>
<TabsV2.Trigger value="models"> <TabsV2.Trigger value="models">
<Icon name="models" size="small" /> <Icon name="models" />
{language.t("settings.models.title")} {language.t("settings.models.title")}
</TabsV2.Trigger> </TabsV2.Trigger>
</div> </div>
@@ -101,9 +99,6 @@ export const DialogSettings: Component<{
<TabsV2.Content value="shortcuts" class="settings-v2-panel"> <TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 /> <SettingsKeybinds v2 />
</TabsV2.Content> </TabsV2.Content>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel"> <TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 /> <SettingsServersV2 />
</TabsV2.Content> </TabsV2.Content>
@@ -1,154 +0,0 @@
import { Component, For, createMemo, createResource } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { ExternalLink } from "../external-link"
import "./settings-v2.css"
interface McpRowItem {
name: string
enabled: boolean
}
interface PluginRowItem {
name: string
}
export const SettingsExtensionsV2: Component = () => {
const language = useLanguage()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const mcps = createMemo<McpRowItem[]>(() => {
const configMcp = serverSync().data.config.mcp ?? {}
return Object.entries(configMcp).map(([name, config]) => ({
name,
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
}))
})
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
const before = serverSync().data.config.mcp ?? {}
const config = before[item.name]
if (typeof config !== "object" || config === null) return
const next = { ...before, [item.name]: { ...config, enabled: checked } }
serverSync().set("config", "mcp", next)
void serverSync()
.updateConfig({ mcp: next })
.catch(() => serverSync().set("config", "mcp", before))
}
const plugins = createMemo<PluginRowItem[]>(() => {
const raw = serverSync().data.config.plugin ?? []
return raw.map((item) => {
const name = typeof item === "string" ? item : item[0]
return { name }
})
})
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
initialValue: [],
})
return (
<>
<div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.extensions")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.extensions.description")}</span>
</div>
</div>
</div>
<div class="settings-v2-tab-body">
<TabsV2 variant="pill" defaultValue="mcps" class="settings-v2-extensions-tabs">
<TabsV2.List>
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
</TabsV2.List>
<TabsV2.Content value="mcps">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={mcps()}>
{(item) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="mcp" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{item.name}</span>
</div>
<Switch checked={item.enabled} onChange={(checked) => handleMcpToggle(item, checked)} hideLabel>
{item.name}
</Switch>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="plugins">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<span class="text-13-regular text-v2-text-faint">{language.t("settings.extensions.manageConfig")}</span>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={plugins()}>
{(plugin) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="code" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate font-mono">{plugin.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
<TabsV2.Content value="skills">
<div class="settings-v2-section">
<div class="flex items-center justify-between">
<span class="text-13-medium text-v2-text-text-base">
{language.t("settings.extensions.availableAll")}
</span>
<ExternalLink
class="text-13-regular text-v2-text-accent hover:underline"
href="https://opencode.ai/docs/skills/"
>
{language.t("settings.extensions.addSkills")}
</ExternalLink>
</div>
<div class="bg-[var(--v2-background-bg-base)] border-[0.5px] border-[var(--v2-border-border-base)] rounded-[8px] pl-4 pr-3 overflow-hidden">
<For each={skills()}>
{(skill) => (
<div class="py-4 flex items-center justify-between border-b-[0.5px] border-[var(--v2-border-border-base)] last:border-b-0">
<div class="flex items-center gap-2.5 min-w-0">
<Icon name="brain" class="text-v2-icon-icon-muted shrink-0" />
<span class="text-13-medium text-v2-text-text-base truncate">{skill.name}</span>
</div>
</div>
)}
</For>
</div>
</div>
</TabsV2.Content>
</TabsV2>
</div>
</>
)
}
@@ -3,6 +3,7 @@ import type { ColorScheme } from "@opencode-ai/ui/theme/context"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { import {
monoDefault, monoDefault,
monoFontFamily, monoFontFamily,
@@ -16,7 +17,6 @@ import {
useSettings, useSettings,
} from "@/context/settings" } from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { useServerSync } from "@/context/server-sync"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
@@ -76,7 +76,9 @@ export function createAppearanceSettingsController() {
const settings = useSettings() const settings = useSettings()
const theme = useTheme() const theme = useTheme()
const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
onMount(() => void theme.loadThemes()) onMount(() => void theme.loadThemes())
return { return {
scheme: { scheme: {
current: theme.colorScheme, current: theme.colorScheme,
@@ -142,6 +144,7 @@ export function createSoundSettingsController() {
preview.play(option.id) preview.play(option.id)
}, },
}) })
return { return {
agent: channel( agent: channel(
settings.sounds.agentEnabled, settings.sounds.agentEnabled,
@@ -163,6 +166,7 @@ export function createSoundSettingsController() {
), ),
} }
} }
export type PermissionScopeController = ReturnType<typeof createPermissionScopeController> export type PermissionScopeController = ReturnType<typeof createPermissionScopeController>
export type ShellSettingsController = ReturnType<typeof createShellSettingsController> export type ShellSettingsController = ReturnType<typeof createShellSettingsController>
export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController> export type AppearanceSettingsController = ReturnType<typeof createAppearanceSettingsController>
@@ -8,7 +8,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action" import { useUpdaterAction } from "../updater-action"
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link" import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row" 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 ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage() const language = useLanguage()
const options = createMemo(() => const options = createMemo(() =>
@@ -357,7 +329,6 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2> <SettingsListV2>
<LanguageSetting /> <LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} /> <PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} /> <ShellSetting controller={shell} />
@@ -420,6 +391,18 @@ export const SettingsGeneralV2: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3> <h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2> <SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2 <SettingsRowV2
title={language.t("settings.general.row.showSearch.title")} title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")} description={language.t("settings.general.row.showSearch.description")}
@@ -9,7 +9,6 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useModels } from "@/context/models" import { useModels } from "@/context/models"
import { useServerSDK } from "@/context/server-sdk" import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { popularProviders } from "@/hooks/use-providers" import { popularProviders } from "@/hooks/use-providers"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
@@ -24,7 +23,6 @@ export const SettingsModelsV2: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
useServerSync()().loadProviders()
const [store, setStore] = persisted( const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }), createStore({ collapsed: {} as Record<string, boolean> }),
@@ -6,11 +6,10 @@ import { showToast } from "@/utils/toast"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { createMemo, type Accessor, type Component, For, Show } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useServerSDK } from "@/context/server-sdk" import { useServerProtocol, useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync" import { useServerSync } from "@/context/server-sync"
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider" import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
import { DialogCustomProvider } from "../dialog-custom-provider" import { DialogCustomProvider } from "../dialog-custom-provider"
import { SettingsServerScope } from "../settings-server-picker"
import { SettingsListV2 } from "./parts/list" import { SettingsListV2 } from "./parts/list"
import "./settings-v2.css" import "./settings-v2.css"
@@ -37,29 +36,20 @@ export const SettingsProvidersV2: Component<{
const dialog = useDialog() const dialog = useDialog()
const language = useLanguage() const language = useLanguage()
const serverSdk = useServerSDK() const serverSdk = useServerSDK()
const protocol = useServerProtocol()
const serverSync = useServerSync() const serverSync = useServerSync()
const providers = useProviders(props.directory) const providers = useProviders(props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack }) const providerConnect = useProviderConnectController({ onBack: props.onBack })
const connect = (provider?: string) => { const connect = (provider?: string) => {
providerConnect.select(provider) providerConnect.select(provider)
void dialog.show(() => ( void dialog.show(() => <DialogConnectProvider directory={props.directory} controller={providerConnect} />)
<SettingsServerScope>
<DialogConnectProvider directory={props.directory} controller={providerConnect} />
</SettingsServerScope>
))
} }
const connected = createMemo(() => { const connected = createMemo(() => {
return providers.connected().filter( return providers
(provider) => .connected()
provider.id !== "opencode" || .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input))
Object.values(provider.models).some((model) => {
if (typeof model !== "object" || model === null || !("cost" in model)) return false
const cost = model.cost
return typeof cost === "object" && cost !== null && "input" in cost
}),
)
}) })
const popular = createMemo(() => { const popular = createMemo(() => {
@@ -91,7 +81,8 @@ export const SettingsProvidersV2: Component<{
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id) const canDisconnect = (item: ProviderItem) =>
source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id))
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
@@ -103,16 +94,41 @@ export const SettingsProvidersV2: Component<{
return true return true
} }
const disableProvider = async (providerID: string, name: string) => {
if (protocol() !== "v1") return
const before = serverSync().data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
await serverSync()
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
}
const disconnect = async (providerID: string, name: string) => { const disconnect = async (providerID: string, name: string) => {
const location = props.directory() ? { directory: props.directory() } : undefined if (isConfigCustom(providerID)) {
await serverSdk()
.client.auth.remove({ providerID })
.catch(() => undefined)
await disableProvider(providerID, name)
return
}
await serverSdk() await serverSdk()
.api.integration.get({ integrationID: providerID, location }) .client.auth.remove({ providerID })
.then(async (integration) => { .then(async () => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? [] await serverSdk().client.global.dispose()
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
)
showToast({ showToast({
variant: "success", variant: "success",
icon: "circle-check", icon: "circle-check",
@@ -129,12 +145,7 @@ export const SettingsProvidersV2: Component<{
return ( return (
<> <>
<div class="settings-v2-tab-header"> <div class="settings-v2-tab-header">
<div class="settings-v2-tab-header-row"> <h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<div class="flex flex-col gap-1">
<h2 class="settings-v2-tab-title">{language.t("settings.providers.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("settings.providers.description")}</span>
</div>
</div>
</div> </div>
<div class="settings-v2-tab-body settings-v2-providers"> <div class="settings-v2-tab-body settings-v2-providers">
@@ -213,7 +224,7 @@ export const SettingsProvidersV2: Component<{
)} )}
</For> </For>
<Show when={false}> <Show when={protocol() === "v1"}>
<div class="settings-v2-provider-row" data-component="custom-provider-section"> <div class="settings-v2-provider-row" data-component="custom-provider-section">
<div class="settings-v2-provider-lead"> <div class="settings-v2-provider-lead">
<ProviderIcon <ProviderIcon
@@ -237,11 +248,7 @@ export const SettingsProvidersV2: Component<{
variant="neutral" variant="neutral"
icon="plus" icon="plus"
onClick={() => { onClick={() => {
dialog.show(() => ( dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
<SettingsServerScope>
<DialogCustomProvider onBack={dialog.close} />
</SettingsServerScope>
))
}} }}
> >
{language.t("common.connect")} {language.t("common.connect")}
@@ -684,223 +684,6 @@
color: var(--v2-text-text-base); color: var(--v2-text-text-base);
} }
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-workspaces-header .settings-v2-tab-title {
font-weight: 610;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 16px;
}
.settings-v2-workspaces-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 15px;
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;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-row-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
}
.settings-v2-workspaces-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.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: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
flex-shrink: 0;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.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"] { [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px; width: 480px;
max-width: calc(100vw - 32px); max-width: calc(100vw - 32px);
@@ -1,475 +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,
sessionsForWorkspace,
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",
transaction: undefined as "confirm" | "running" | undefined,
})
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) => {
if (!sessionQuery.isSuccess) return []
return 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.plural("settings.workspaces.sessions", count, {
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 sessionTime = (session: Session) => {
if (!session.time.updated) return undefined
return getRelativeTime(new Date(session.time.updated).toISOString(), language.t)
}
const inspect = async (workspace: Workspace, context = captureDeleteContext()) => {
const [working, branch, sessions] = await Promise.all([
context.sdk.api.vcs.status({ location: { directory: workspace.directory } }),
context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }),
loadSessions(context),
])
const result = inspectWorkspaceDeletion({
workspace: workspace.directory,
activeDirectory: context.activeDirectory,
sessions,
status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean",
})
return { result, 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")
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, allowDirty = false, context = captureDeleteContext()) => {
const preflight = await inspect(workspace, context)
if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) {
blocked(preflight.result)
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 (!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)
}
const transact = async (task: () => Promise<void>) => {
if (store.transaction !== "confirm") return
setStore("transaction", "running")
try {
await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}
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, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, 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.plural("settings.workspaces.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-row-header">
<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>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</div>
)}
</For>
</div>
</Show>
</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>
)
}
+1 -17
View File
@@ -353,22 +353,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
keybind: "mod+shift+t", keybind: "mod+shift+t",
onSelect: () => tabsStoreActions.reopenClosedTab(), 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) ].filter((v) => v !== undefined)
}) })
@@ -644,7 +628,7 @@ function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) {
aria-label={props.state.ariaLabel} aria-label={props.state.ariaLabel}
> >
<span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0"> <span class="shrink-0 ml-[8px] mr-px text-[11px] text-v2-text-text-accent [font-weight:530] opacity-0 translate-x-2 motion-safe:transition-all duration-150 ease-out group-hover:opacity-100 group-hover:translate-x-0 group-focus-within:opacity-100 group-focus-within:translate-x-0 motion-reduce:translate-x-0">
{props.state.label} Update
</span> </span>
<span class="flex size-5 shrink-0 items-center justify-center"> <span class="flex size-5 shrink-0 items-center justify-center">
<Show <Show
@@ -152,6 +152,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient queryClient: QueryClient
}) { }) {
const slow = [ 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 input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project))
@@ -518,6 +524,17 @@ export async function bootstrapDirectory(input: {
input.queryClient.fetchQuery( input.queryClient.fetchQuery(
loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), 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>)[] ].filter(Boolean) as (() => Promise<any>)[]
await waitForPaint() await waitForPaint()
@@ -263,13 +263,10 @@ describe("createChildStoreManager", () => {
manager.child("/project") manager.child("/project")
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
expect(queries[3]?.().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(queries[5]?.().enabled).toBe(true)
expect(bootstraps).toEqual(["/project"]) expect(bootstraps).toEqual(["/project"])
manager.enableProviders("/project")
expect(queries[4]?.().enabled).toBe(true)
manager.child("/project", { bootstrap: false }) manager.child("/project", { bootstrap: false })
expect(queries[0]?.().enabled).toBe(true) expect(queries[0]?.().enabled).toBe(true)
} finally { } finally {
@@ -47,7 +47,6 @@ export function createChildStoreManager(input: {
const mcpToggles = new Map<string, (enabled: boolean) => void>() const mcpToggles = new Map<string, (enabled: boolean) => void>()
const activeDirectories = new Set<string>() const activeDirectories = new Set<string>()
const activationToggles = new Map<string, (enabled: boolean) => void>() const activationToggles = new Map<string, (enabled: boolean) => void>()
const providerToggles = new Map<string, (enabled: boolean) => void>()
const markKey = (key: DirectoryKey) => { const markKey = (key: DirectoryKey) => {
if (!key) return if (!key) return
@@ -123,7 +122,6 @@ export function createChildStoreManager(input: {
mcpToggles.delete(key) mcpToggles.delete(key)
activeDirectories.delete(key) activeDirectories.delete(key)
activationToggles.delete(key) activationToggles.delete(key)
providerToggles.delete(key)
const dispose = disposers.get(key) const dispose = disposers.get(key)
if (dispose) { if (dispose) {
dispose() dispose()
@@ -189,7 +187,6 @@ export function createChildStoreManager(input: {
const initialIcon = icon[0].value const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false) const [mcpEnabled, setMcpEnabled] = createSignal(false)
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false) const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
const [providerEnabled, setProviderEnabled] = createSignal(false)
const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() })) const pathQuery = useQuery(() => ({ ...input.queryOptions.path(key), enabled: instanceQueriesEnabled() }))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() })) 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 lspQuery = useQuery(() => ({ ...input.queryOptions.lsp(key), enabled: instanceQueriesEnabled() }))
const providerQuery = useQuery(() => ({ const providerQuery = useQuery(() => ({
...input.queryOptions.providers(key), ...input.queryOptions.providers(key),
enabled: providerEnabled(), enabled: instanceQueriesEnabled(),
})) }))
const referenceQuery = useQuery(() => ({ const referenceQuery = useQuery(() => ({
...input.queryOptions.references(key), ...input.queryOptions.references(key),
@@ -209,7 +206,7 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return providerEnabled() && !providerQuery.isLoading return instanceQueriesEnabled() && !providerQuery.isLoading
}, },
get provider() { get provider() {
const EMPTY = { all: new Map(), connected: [], default: {} } const EMPTY = { all: new Map(), connected: [], default: {} }
@@ -266,7 +263,6 @@ export function createChildStoreManager(input: {
disposers.set(key, dispose) disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled) mcpToggles.set(key, setMcpEnabled)
activationToggles.set(key, setInstanceQueriesEnabled) activationToggles.set(key, setInstanceQueriesEnabled)
providerToggles.set(key, setProviderEnabled)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return if (!(init instanceof Promise)) return
@@ -333,12 +329,6 @@ export function createChildStoreManager(input: {
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1]) 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. // Passive Home/project metadata reads must not initialize the directory.
// A real directory access enables these queries once for the store lifetime. // A real directory access enables these queries once for the store lifetime.
// TODO(v2): After Home switches to v2.project.list and root-filtered, // 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)), mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
active: (directory: string) => activeDirectories.has(directoryKey(directory)), active: (directory: string) => activeDirectories.has(directoryKey(directory)),
disableMcp, disableMcp,
enableProviders,
disposeDirectory, disposeDirectory,
runEviction, runEviction,
vcsCache, vcsCache,
@@ -15,12 +15,12 @@ const rootSession = (input: { id: string; parentID?: string; archived?: number }
}, },
}) as Session }) as Session
const userMessage = (id: string, sessionID: string, created = 1) => const userMessage = (id: string, sessionID: string) =>
({ ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created }, time: { created: 1 },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) as Message }) as Message
@@ -370,13 +370,13 @@ describe("applyDirectoryEvent", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const [store, setStore] = createStore( const [store, setStore] = createStore(
baseState({ baseState({
message: { [sessionID]: [userMessage("msg_z", sessionID, 1), userMessage("msg_b", sessionID, 3)] }, message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] },
part: { msg_a: [textPart("prt_1", sessionID, "msg_a")] }, part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] },
}), }),
) )
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.updated", properties: { info: userMessage("msg_a", sessionID, 2) } }, event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -384,14 +384,14 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a", "msg_b"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"])
applyDirectoryEvent({ applyDirectoryEvent({
event: { event: {
type: "message.updated", type: "message.updated",
properties: { properties: {
info: { info: {
...userMessage("msg_a", sessionID, 2), ...userMessage("msg_2", sessionID),
role: "assistant", role: "assistant",
} as Message, } as Message,
}, },
@@ -403,10 +403,10 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.find((x) => x.id === "msg_a")?.role).toBe("assistant") expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant")
applyDirectoryEvent({ applyDirectoryEvent({
event: { type: "message.removed", properties: { sessionID, messageID: "msg_a" } }, event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } },
store, store,
setStore, setStore,
push() {}, push() {},
@@ -414,8 +414,8 @@ describe("applyDirectoryEvent", () => {
loadLsp() {}, loadLsp() {},
}) })
expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_b"]) expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"])
expect(store.part.msg_a).toBeUndefined() expect(store.part.msg_2).toBeUndefined()
}) })
test("upserts and prunes message parts", () => { test("upserts and prunes message parts", () => {
@@ -15,7 +15,6 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
import { diffs as list, message as clean } from "@/utils/diffs" import { diffs as list, message as clean } from "@/utils/diffs"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const SESSION_CONTENT_EVENTS = new Set([ const SESSION_CONTENT_EVENTS = new Set([
@@ -276,7 +275,7 @@ export function applyDirectoryEvent(input: {
input.setStore("message", info.sessionID, [info]) input.setStore("message", info.sessionID, [info])
break break
} }
const result = Binary.search(messages, messageKey(info), messageKey) const result = Binary.search(messages, info.id, (m) => m.id)
if (result.found) { if (result.found) {
input.setStore("message", info.sessionID, result.index, reconcile(info)) input.setStore("message", info.sessionID, result.index, reconcile(info))
break break
@@ -296,8 +295,8 @@ export function applyDirectoryEvent(input: {
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
if (messages) { if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID) const result = Binary.search(messages, props.messageID, (m) => m.id)
if (index >= 0) messages.splice(index, 1) if (result.found) messages.splice(result.index, 1)
} }
const parts = draft.part[props.messageID] const parts = draft.part[props.messageID]
if (parts) { if (parts) {
@@ -323,7 +322,7 @@ export function applyDirectoryEvent(input: {
input.setStore("part", part.messageID, [part]) input.setStore("part", part.messageID, [part])
break break
} }
const result = Binary.search(parts, part.id, (item) => item.id) const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) { if (result.found) {
input.setStore("part", part.messageID, result.index, reconcile(part)) input.setStore("part", part.messageID, result.index, reconcile(part))
break break
@@ -346,13 +345,13 @@ export function applyDirectoryEvent(input: {
) )
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (p) => p.id)
if (result.found) { if (result.found) {
input.setStore( input.setStore(
produce((draft) => { produce((draft) => {
const list = draft.part[props.messageID] const list = draft.part[props.messageID]
if (!list) return if (!list) return
const next = Binary.search(list, props.partID, (part) => part.id) const next = Binary.search(list, props.partID, (p) => p.id)
if (!next.found) return if (!next.found) return
list.splice(next.index, 1) list.splice(next.index, 1)
if (list.length === 0) delete draft.part[props.messageID] if (list.length === 0) delete draft.part[props.messageID]
@@ -365,7 +364,7 @@ export function applyDirectoryEvent(input: {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string } const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID] const parts = input.store.part[props.messageID]
if (!parts) break if (!parts) break
const result = Binary.search(parts, props.partID, (part) => part.id) const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break if (!result.found) break
const field = props.field as keyof (typeof parts)[number] const field = props.field as keyof (typeof parts)[number]
const current = parts[result.index]?.[field] const current = parts[result.index]?.[field]
@@ -22,7 +22,6 @@ export const homeSessionIndexKey = (server: string) => ["home", "session-index",
export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const export const homeSessionEventsKey = (server: string) => ["home", "session-events", server] as const
type HomeSessionPage = { data?: V2SessionListResponse } type HomeSessionPage = { data?: V2SessionListResponse }
type ProjectedHomeSessionPage = { data?: { data: Session[]; cursor: { next?: string } } }
export async function loadHomeSessionIndex( export async function loadHomeSessionIndex(
list: ( list: (
@@ -32,30 +31,7 @@ export async function loadHomeSessionIndex(
eventSequence = 0, eventSequence = 0,
signal?: AbortSignal, signal?: AbortSignal,
) { ) {
return loadHomeSessionPages(list, parseHomeSessionIndex, eventSequence, signal) const data: SessionV2Info[] = []
}
export async function loadProjectedHomeSessionIndex(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<ProjectedHomeSessionPage>,
eventSequence = 0,
signal?: AbortSignal,
) {
return loadHomeSessionPages(list, (sessions) => sessions, eventSequence, signal)
}
async function loadHomeSessionPages<T>(
list: (
input: { limit: number; order: "desc"; cursor?: string },
options: { signal?: AbortSignal },
) => Promise<{ data?: { data: T[]; cursor: { next?: string } } }>,
project: (sessions: T[]) => Session[],
eventSequence: number,
signal?: AbortSignal,
) {
const data: T[] = []
let cursor: string | undefined let cursor: string | undefined
for (;;) { for (;;) {
@@ -70,7 +46,7 @@ async function loadHomeSessionPages<T>(
const page = response.data! const page = response.data!
data.push(...page.data) data.push(...page.data)
if (page.data.length < HOME_V2_SESSION_PAGE_LIMIT || !page.cursor.next) 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 cursor = page.cursor.next
} }
} }
+138 -77
View File
@@ -2,22 +2,14 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js" import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
pluralCategory,
type UiI18nPluralLookupKey,
type UiI18nPluralKey,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { import {
createDesktopNativeBundle, createDesktopNativeBundle,
detectDesktopNativeLocale,
DESKTOP_NATIVE_ENGLISH, DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES, DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
type DesktopNativeBundle, type DesktopNativeBundle,
type DesktopNativeLocale, type DesktopNativeLocale,
} from "@/i18n/desktop-native" } from "@/i18n/desktop-native"
@@ -25,7 +17,7 @@ import {
export type Locale = DesktopNativeLocale export type Locale = DesktopNativeLocale
export type Direction = "ltr" | "rtl" export type Direction = "ltr" | "rtl"
const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa", "fa", "dv"]) const RTL_LOCALES: ReadonlySet<Locale> = new Set(["ar", "ur", "pa"])
function localeDirection(locale: Locale): Direction { function localeDirection(locale: Locale): Direction {
return RTL_LOCALES.has(locale) ? "rtl" : "ltr" return RTL_LOCALES.has(locale) ? "rtl" : "ltr"
@@ -33,13 +25,11 @@ function localeDirection(locale: Locale): Direction {
type RawDictionary = typeof en & typeof uiEn type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
type AppI18nKey = Extract<keyof typeof en, string> type PluralKey =
type AppI18nPluralKey = { | UiI18nPluralKey
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never | "session.question.pending"
}[AppI18nKey] | "session.followupDock.summary"
type PluralKey = AppI18nPluralKey | UiI18nPluralKey | "session.revertDock.summary"
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
type Source = { dict: Record<string, string> } type Source = { dict: Record<string, string> }
function cookie(locale: Locale) { function cookie(locale: Locale) {
@@ -48,7 +38,70 @@ function cookie(locale: Locale) {
const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES
const INTL = DESKTOP_NATIVE_LOCALE_TAGS const INTL: Record<Locale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
bs: "bs",
tr: "tr",
hi: "hi-IN",
nl: "nl-NL",
id: "id-ID",
vi: "vi-VN",
it: "it-IT",
ur: "ur-PK",
pa: "pa-Arab-PK",
az: "az-Latn-AZ",
fi: "fi-FI",
sv: "sv-SE",
}
const LABEL_KEY: Partial<Record<Locale, keyof Dictionary>> = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
ko: "language.ko",
de: "language.de",
es: "language.es",
fr: "language.fr",
da: "language.da",
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
uk: "language.uk",
ar: "language.ar",
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
tr: "language.tr",
}
const LABEL: Partial<Record<Locale, string>> = {
hi: "हिन्दी",
nl: "Nederlands",
id: "Bahasa Indonesia",
vi: "Tiếng Việt",
it: "Italiano",
ur: "اردو",
pa: "پنجابی",
az: "Azərbaycanca",
fi: "Suomi",
sv: "Svenska",
}
const base = i18n.flatten({ ...en, ...uiEn }) const base = i18n.flatten({ ...en, ...uiEn })
const dicts = new Map<Locale, Dictionary>([["en", base]]) const dicts = new Map<Locale, Dictionary>([["en", base]])
@@ -84,40 +137,6 @@ const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")), az: () => merge(import("@/i18n/az"), import("@opencode-ai/ui/i18n/az")),
fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")), fi: () => merge(import("@/i18n/fi"), import("@opencode-ai/ui/i18n/fi")),
sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")), sv: () => merge(import("@/i18n/sv"), import("@opencode-ai/ui/i18n/sv")),
am: () => merge(import("@/i18n/am"), import("@opencode-ai/ui/i18n/am")),
bg: () => merge(import("@/i18n/bg"), import("@opencode-ai/ui/i18n/bg")),
bn: () => merge(import("@/i18n/bn"), import("@opencode-ai/ui/i18n/bn")),
ca: () => merge(import("@/i18n/ca"), import("@opencode-ai/ui/i18n/ca")),
cs: () => merge(import("@/i18n/cs"), import("@opencode-ai/ui/i18n/cs")),
dv: () => merge(import("@/i18n/dv"), import("@opencode-ai/ui/i18n/dv")),
dz: () => merge(import("@/i18n/dz"), import("@opencode-ai/ui/i18n/dz")),
el: () => merge(import("@/i18n/el"), import("@opencode-ai/ui/i18n/el")),
et: () => merge(import("@/i18n/et"), import("@opencode-ai/ui/i18n/et")),
fa: () => merge(import("@/i18n/fa"), import("@opencode-ai/ui/i18n/fa")),
fo: () => merge(import("@/i18n/fo"), import("@opencode-ai/ui/i18n/fo")),
hr: () => merge(import("@/i18n/hr"), import("@opencode-ai/ui/i18n/hr")),
hu: () => merge(import("@/i18n/hu"), import("@opencode-ai/ui/i18n/hu")),
hy: () => merge(import("@/i18n/hy"), import("@opencode-ai/ui/i18n/hy")),
is: () => merge(import("@/i18n/is"), import("@opencode-ai/ui/i18n/is")),
ka: () => merge(import("@/i18n/ka"), import("@opencode-ai/ui/i18n/ka")),
km: () => merge(import("@/i18n/km"), import("@opencode-ai/ui/i18n/km")),
lo: () => merge(import("@/i18n/lo"), import("@opencode-ai/ui/i18n/lo")),
lt: () => merge(import("@/i18n/lt"), import("@opencode-ai/ui/i18n/lt")),
lv: () => merge(import("@/i18n/lv"), import("@opencode-ai/ui/i18n/lv")),
mk: () => merge(import("@/i18n/mk"), import("@opencode-ai/ui/i18n/mk")),
mn: () => merge(import("@/i18n/mn"), import("@opencode-ai/ui/i18n/mn")),
ms: () => merge(import("@/i18n/ms"), import("@opencode-ai/ui/i18n/ms")),
my: () => merge(import("@/i18n/my"), import("@opencode-ai/ui/i18n/my")),
ne: () => merge(import("@/i18n/ne"), import("@opencode-ai/ui/i18n/ne")),
ro: () => merge(import("@/i18n/ro"), import("@opencode-ai/ui/i18n/ro")),
si: () => merge(import("@/i18n/si"), import("@opencode-ai/ui/i18n/si")),
sk: () => merge(import("@/i18n/sk"), import("@opencode-ai/ui/i18n/sk")),
sl: () => merge(import("@/i18n/sl"), import("@opencode-ai/ui/i18n/sl")),
sq: () => merge(import("@/i18n/sq"), import("@opencode-ai/ui/i18n/sq")),
sr: () => merge(import("@/i18n/sr"), import("@opencode-ai/ui/i18n/sr")),
tg: () => merge(import("@/i18n/tg"), import("@opencode-ai/ui/i18n/tg")),
tk: () => merge(import("@/i18n/tk"), import("@opencode-ai/ui/i18n/tk")),
uz: () => merge(import("@/i18n/uz"), import("@opencode-ai/ui/i18n/uz")),
} }
function loadDict(locale: Locale) { function loadDict(locale: Locale) {
@@ -135,9 +154,63 @@ export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined) return loadDict(locale).then(() => undefined)
} }
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "en", match: (language) => language.startsWith("en") },
{
locale: "zht",
match: (language) =>
language.startsWith("zh") &&
(language.includes("hant") || language.includes("-tw") || language.includes("-hk") || language.includes("-mo")),
},
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
{ locale: "de", match: (language) => language.startsWith("de") },
{ locale: "es", match: (language) => language.startsWith("es") },
{ locale: "fr", match: (language) => language.startsWith("fr") },
{ locale: "da", match: (language) => language.startsWith("da") },
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "uk", match: (language) => language.startsWith("uk") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
},
{ locale: "br", match: (language) => language.startsWith("pt") },
{ locale: "th", match: (language) => language.startsWith("th") },
{ locale: "bs", match: (language) => language.startsWith("bs") },
{ locale: "tr", match: (language) => language.startsWith("tr") },
{ locale: "hi", match: (language) => language.startsWith("hi") },
{ locale: "nl", match: (language) => language.startsWith("nl") },
{ locale: "id", match: (language) => language.startsWith("id") },
{ locale: "vi", match: (language) => language.startsWith("vi") },
{ locale: "it", match: (language) => language.startsWith("it") },
{ locale: "ur", match: (language) => language.startsWith("ur") },
{
locale: "pa",
match: (language) => language.startsWith("pa") && (language.includes("arab") || language.includes("-pk")),
},
{
locale: "az",
match: (language) => language.startsWith("az") && !language.includes("arab") && !language.includes("cyrl"),
},
{ locale: "fi", match: (language) => language.startsWith("fi") },
{ locale: "sv", match: (language) => language.startsWith("sv") },
]
function detectLocale(): Locale { function detectLocale(): Locale {
if (typeof navigator !== "object") return "en" if (typeof navigator !== "object") return "en"
return detectDesktopNativeLocale(navigator.languages?.length ? navigator.languages : [navigator.language])
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
const normalized = language.toLowerCase()
const match = localeMatchers.find((entry) => entry.match(normalized))
if (match) return match.locale
}
return "en"
} }
export function normalizeLocale(value: string): Locale { export function normalizeLocale(value: string): Locale {
@@ -158,17 +231,7 @@ function readStoredLocale() {
} }
const warm = readStoredLocale() ?? detectLocale() const warm = readStoredLocale() ?? detectLocale()
const initialLocale = if (warm !== "en") void loadDict(warm)
warm === "en"
? Promise.resolve(warm)
: loadDict(warm).then(
() => warm,
() => "en" as const,
)
export function loadInitialLocale() {
return initialLocale
}
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
@@ -196,30 +259,29 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
initialValue: dicts.get(initial) ?? base, initialValue: dicts.get(initial) ?? base,
}) })
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>( const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: TranslationKey<Key>, key: keyof Dictionary,
params?: Record<string, string | number | boolean>, params?: Record<string, string | number | boolean>,
) => string ) => string
const pluralForm = ( const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
key: PluralKey, const category = pluralCategory(intl(), count)
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string> const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}` const candidate = `${key}.${category}`
const fallback = `${key}.other` const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params) return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
} }
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value] const label = (value: Locale) => {
const key = LABEL_KEY[value]
if (key) return t(key)
return LABEL[value] ?? value
}
createEffect(() => { createEffect(() => {
if (typeof document !== "object") return if (typeof document !== "object") return
const value = locale() const value = locale()
document.documentElement.lang = intl() document.documentElement.lang = value
document.documentElement.dir = direction() document.documentElement.dir = direction()
document.cookie = cookie(value) document.cookie = cookie(value)
}) })
@@ -243,7 +305,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label, label,
t, t,
plural, plural,
pluralForm,
setLocale(next: Locale) { setLocale(next: Locale) {
setStore("locale", normalizeLocale(next)) setStore("locale", normalizeLocale(next))
}, },
+7 -12
View File
@@ -8,7 +8,6 @@ import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings" import { useSettings } from "./settings"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs" import { useTabs, type Tab } from "./tabs"
import type { ServerScope } from "@/utils/server-scope"
import { import {
createPromptReady, createPromptReady,
createPromptSession, createPromptSession,
@@ -105,13 +104,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope => const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => { const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey()) if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
: undefined
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope) const key = scopeKey(scope)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) { if (existing) {
cache.delete(key) cache.delete(key)
@@ -121,7 +118,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot( const entry = createRoot(
(dispose) => ({ (dispose) => ({
value: createPromptSession(target?.scope ?? serverSDK().scope, scope), value: createPromptSession(serverSDK().scope, scope),
dispose, dispose,
}), }),
owner, owner,
@@ -133,8 +130,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
} }
const session = createMemo(() => load(scope())) const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
scope ? load(scope, target) : session()
const ready = createPromptReady(session) const ready = createPromptReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) => const withSuspense = <T,>(cb: () => T): (() => T) =>
@@ -150,8 +146,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return { return {
ready, ready,
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => capture: (scope?: PromptScope) => pick(scope).capture(),
pick(scope, target).capture(),
current: withSuspense(() => session().current()), current: withSuspense(() => session().current()),
cursor: withSuspense(() => session().cursor()), cursor: withSuspense(() => session().cursor()),
dirty: withSuspense(() => session().dirty()), dirty: withSuspense(() => session().dirty()),
+1 -83
View File
@@ -1,89 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
adaptServerEvent,
adaptWorktreeCompatibilityEvent,
applyWorkspaceOperationEvent,
applyWorktreeEvent,
coalesceServerEvents,
enqueueServerEvent,
resumeStreamAfterPageShow,
} from "./server-sdk"
import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { OpenCodeEvent } from "@opencode-ai/client/promise"
import type { Event } from "@opencode-ai/sdk/v2/client" 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" })
expect(
adaptWorktreeCompatibilityEvent({
directory: "/repo",
payload: {
id: "status",
type: "session.status",
properties: { sessionID: "session", status: { type: "idle" } },
} as Event,
}),
).toBeUndefined()
})
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("legacy and current moved events complete matching operations", () => {
const events = [
{
id: "legacy",
payload: {
id: "moved",
type: "session.next.moved",
properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } },
} as Event,
},
{
id: "current",
payload: adaptServerEvent({
id: "moved-current",
created: Date.now(),
type: "session.moved",
data: { sessionID: "current", location: { directory: "/workspace" } },
} as OpenCodeEvent),
},
]
events.forEach((event) => {
WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace")
applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload })
expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete")
})
})
describe("resumeStreamAfterPageShow", () => { describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => { test("restarts a stream only after a back-forward cache restore", () => {
+3 -101
View File
@@ -13,10 +13,6 @@ import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol"
import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" 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) => const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" 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 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) => { const coalescedKey = (event: QueuedServerEvent) => {
if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}`
if (event.payload.type === "message.part.updated") { if (event.payload.type === "message.part.updated") {
@@ -147,31 +138,6 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output return output
} }
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
if (event.payload.type === "worktree.ready") {
Worktree.ready(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)
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 { function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
if ( if (
event?.type === "session.text.delta" || event?.type === "session.text.delta" ||
@@ -220,7 +186,6 @@ type ServerSDKBase = {
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage()
const abort = new AbortController() const abort = new AbortController()
const eventFetch = (() => { const eventFetch = (() => {
@@ -273,11 +238,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now() last = Date.now()
const output = coalesceServerEvents(events) const output = coalesceServerEvents(events)
batch(() => { batch(() => {
output.forEach((event) => { output.forEach((event) => emitter.emit(event.directory, event.payload))
applyWorktreeEvent(scope, event, language.t("common.requestFailed"))
applyWorkspaceOperationEvent(scope, event)
emitter.emit(event.directory, event.payload)
})
}) })
buffer.length = 0 buffer.length = 0
@@ -290,57 +251,12 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
let streamErrorLogged = false let streamErrorLogged = false
let worktreeStreamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined let attempt: AbortController | undefined
let worktreeAttempt: AbortController | undefined
let run: Promise<void> | undefined let run: Promise<void> | undefined
let started = false let started = false
let generation = 0 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 = () => { const start = () => {
if (started) return run if (started) return run
started = true started = true
@@ -348,8 +264,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
const previous = run const previous = run
const current = (async () => { const current = (async () => {
if (previous) await previous 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 // 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) { while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController() attempt = new AbortController()
@@ -358,6 +272,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
} }
abort.signal.addEventListener("abort", onAbort) abort.signal.addEventListener("abort", onAbort)
try { try {
const kind = await protocol
const events = const events =
kind === "v1" kind === "v1"
? (await eventSdk.global.event({ signal: attempt.signal })).stream ? (await eventSdk.global.event({ signal: attempt.signal })).stream
@@ -405,7 +320,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
started = false started = false
generation++ generation++
attempt?.abort() attempt?.abort()
worktreeAttempt?.abort()
} }
onMount(() => { onMount(() => {
@@ -432,7 +346,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
throwOnError: true, throwOnError: true,
directory, directory,
}) })
const api = createCompatibleApi({ protocol, current: currentApi, legacy, decodeVcsDiff, decodeSessionList }) const api = createCompatibleApi({ protocol, current: currentApi, legacy })
return { return {
server, server,
@@ -518,19 +432,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) {
current: serverSDK.currentApi, current: serverSDK.currentApi,
legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }),
directory, 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, event: emitter,
get url() { get url() {
return serverSDK.url return serverSDK.url
@@ -223,23 +223,6 @@ describe("server session", () => {
expect(ctx.store.lineage.peek("child")).toEqual(result) 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("loads session content through the server client", async () => { test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") }) const ctx = setup({ root: session("root") })
@@ -281,7 +264,6 @@ describe("server session", () => {
expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
}) })
test("extends a current page to include the user for split assistant turns", async () => { test("extends a current page to include the user for split assistant turns", async () => {
@@ -1515,7 +1497,7 @@ describe("server session", () => {
await store.sync("child", { force: true }) await store.sync("child", { force: true })
expect(store.data.message.child).toEqual([older, boundary]) expect(store.data.message.child).toEqual([boundary, older])
}) })
test("preserves a part update for a message being loaded from history", async () => { test("preserves a part update for a message being loaded from history", async () => {
+37 -86
View File
@@ -18,27 +18,21 @@ import { message as cleanMessage } from "@/utils/diffs"
import { sessionNotFoundError } from "@/utils/server-errors" import { sessionNotFoundError } from "@/utils/server-errors"
import { rootSession } from "@/utils/session-route" import { rootSession } from "@/utils/session-route"
import { normalizeSessionInfo } from "@/utils/session" import { normalizeSessionInfo } from "@/utils/session"
import { compareMessages, messageKey, normalizeSessionMessages } from "@/utils/session-message" import { normalizeSessionMessages } from "@/utils/session-message"
import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache"
import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import type { DecodedLegacyMessagePage } from "./session-message-decode"
type MessageApi = ServerApi["message"] type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) 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 SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20 const initialMessagePageSize = 20
const historyMessagePageSize = 50 const historyMessagePageSize = 200
const sessionInfoLimit = 2_048 const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set() 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[]) { function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find( const boundary = source.find(
(message) => (message) =>
@@ -70,7 +64,7 @@ type MessagePage = {
function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] {
return items return items
.slice() .slice()
.sort((a, b) => compareMessages(a.info, b.info)) .sort((a, b) => cmp(a.info.id, b.info.id))
.map((item) => { .map((item) => {
if (item.info.role === "user") { if (item.info.role === "user") {
return { return {
@@ -117,16 +111,17 @@ function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
const part = new Map(page.part.map((item) => [item.id, item.part])) const part = new Map(page.part.map((item) => [item.id, item.part]))
const observed: { messageID: string; parts: Part[] }[] = [] const observed: { messageID: string; parts: Part[] }[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey) const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found if (!result.found) session.splice(result.index, 0, item.message)
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id) const current = part.get(item.message.id)
const confirmed = found ? item.parts.filter((part) => current?.some((value) => value.id === part.id)) : [] const confirmed = result.found
if (found) observed.push({ messageID: item.message.id, parts: confirmed }) ? item.parts.filter((part) => Binary.search(current ?? [], part.id, (value) => value.id).found)
: []
if (result.found) observed.push({ messageID: item.message.id, parts: confirmed })
part.set( part.set(
item.message.id, item.message.id,
merge( merge(
found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []), result.found ? (current ?? []) : merge(item.confirmedParts ?? [], current ?? []),
item.parts.filter((part) => !confirmed.includes(part)), item.parts.filter((part) => !confirmed.includes(part)),
), ),
) )
@@ -163,7 +158,6 @@ function reconcileFetched<T extends { id: string }>(
retained?: ReadonlySet<string> retained?: ReadonlySet<string>
removed?: ReadonlySet<string> removed?: ReadonlySet<string>
preserveUnfetched?: boolean | ((item: T) => boolean) preserveUnfetched?: boolean | ((item: T) => boolean)
compare?: (a: T, b: T) => number
} = {}, } = {},
) { ) {
const result = new Map(fetched.map((item) => [item.id, item])) const result = new Map(fetched.map((item) => [item.id, item]))
@@ -186,15 +180,10 @@ function reconcileFetched<T extends { id: string }>(
if (!item) result.delete(id) if (!item) result.delete(id)
} }
for (const id of options.removed ?? emptyIDs) result.delete(id) for (const id of options.removed ?? emptyIDs) result.delete(id)
const items = [...result.values()] return [...result.values()].sort((a, b) => cmp(a.id, b.id))
return options.compare ? items.sort(options.compare) : items
} }
type ServerSessionOptions = { type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> }
retry?: typeof retry
protocol?: Promise<"v1" | "v2">
decodeMessages?: (buffer: ArrayBuffer) => Promise<DecodedLegacyMessagePage>
}
export function createServerSession( export function createServerSession(
client: OpencodeClient, client: OpencodeClient,
@@ -312,24 +301,22 @@ export function createServerSession(
return session return session
} }
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => { const resolve = (sessionID: string, options?: { force?: boolean }) => {
const cached = data.info[sessionID] const cached = data.info[sessionID]
if (cached && !options?.force) return Promise.resolve(cached) 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 if (pending) return pending
const active = generation(sessionID) const active = generation(sessionID)
const request = sessionApi const request = sessionApi
? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo) ? sessionApi.get({ sessionID }).then(normalizeSessionInfo)
: client.session.get({ sessionID }, { signal: options?.signal }).then((result) => { : client.session.get({ sessionID }).then((result) => {
if (!result.data) throw sessionNotFoundError(sessionID) if (!result.data) throw sessionNotFoundError(sessionID)
return result.data return result.data
}) })
const resolved = request.then((result) => { const resolved = request.then((result) => {
if (options?.signal?.aborted) return result
if (generations.get(sessionID) !== active) return result if (generations.get(sessionID) !== active) return result
return remember(result) return remember(result)
}) })
if (options?.signal) return resolved
requests.set(sessionID, resolved) requests.set(sessionID, resolved)
const cleanup = () => { const cleanup = () => {
if (requests.get(sessionID) === resolved) requests.delete(sessionID) if (requests.get(sessionID) === resolved) requests.delete(sessionID)
@@ -426,7 +413,8 @@ export function createServerSession(
if (!load) return if (!load) return
// A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata. // A part event keeps an existing parent when the fetched page omits it without overriding fetched metadata.
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
const parts = load.touchedParts.get(messageID) const parts = load.touchedParts.get(messageID)
if (parts) { if (parts) {
parts.add(partID) parts.add(partID)
@@ -449,14 +437,16 @@ export function createServerSession(
load.touchedParts.set(messageID, new Set(parts)) load.touchedParts.set(messageID, new Set(parts))
load.carriedDeltaParts.set(messageID, new Set(parts)) load.carriedDeltaParts.set(messageID, new Set(parts))
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.removedParts) { for (const [messageID, parts] of load.removedParts) {
const touched = load.touchedParts.get(messageID) ?? new Set<string>() const touched = load.touchedParts.get(messageID) ?? new Set<string>()
parts.forEach((partID) => touched.add(partID)) parts.forEach((partID) => touched.add(partID))
load.touchedParts.set(messageID, touched) load.touchedParts.set(messageID, touched)
const messages = data.message[sessionID] const messages = data.message[sessionID]
if (messages?.some((message) => message.id === messageID)) load.retainedMessages.add(messageID) if (messages && Binary.search(messages, messageID, (message) => message.id).found)
load.retainedMessages.add(messageID)
} }
for (const [messageID, parts] of load.optimisticParts) { for (const [messageID, parts] of load.optimisticParts) {
load.removedMessages.delete(messageID) load.removedMessages.delete(messageID)
@@ -562,11 +552,10 @@ export function createServerSession(
if (!response.data.length) break if (!response.data.length) break
} }
const response = pages.at(-1)! const response = pages.at(-1)!
await yieldToMain()
const source = pages.flatMap((page) => page.data).toReversed() const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
@@ -577,24 +566,13 @@ export function createServerSession(
complete: response.data.length === 0, complete: response.data.length === 0,
} }
} }
const response = await (options?.retry ?? retry)(async () => { const response = await (options?.retry ?? retry)(() => {
onAttempt?.() onAttempt?.()
if (!options?.decodeMessages) return client.session.messages({ sessionID, limit, before }) return client.session.messages({ sessionID, limit, before })
const response = await client.session.messages({ sessionID, limit, before }, { parseAs: "arrayBuffer" })
if (!(response.data instanceof ArrayBuffer)) throw new Error("Session messages response is not an ArrayBuffer")
return { response, decoded: await options.decodeMessages(response.data) }
}) })
await yieldToMain()
if ("decoded" in response)
return {
...response.decoded,
sourceMode: before ? ("older" as const) : ("latest" as const),
cursor: response.response.response.headers.get("x-next-cursor") ?? undefined,
complete: !response.response.response.headers.get("x-next-cursor"),
}
const items = (response.data ?? []).filter((item) => !!item?.info?.id) const items = (response.data ?? []).filter((item) => !!item?.info?.id)
return { return {
session: items.map((item) => cleanMessage(item.info)).sort(compareMessages), session: items.map((item) => cleanMessage(item.info)).sort((a, b) => cmp(a.id, b.id)),
part: items.map((item) => ({ part: items.map((item) => ({
id: item.info.id, id: item.info.id,
part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
@@ -718,7 +696,7 @@ export function createServerSession(
const normalized = normalizeSessionMessages(sessionID, source) const normalized = normalizeSessionMessages(sessionID, source)
return { return {
...page, ...page,
session: normalized.messages.sort(compareMessages), session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
part: [...normalized.parts.entries()] part: [...normalized.parts.entries()]
.map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) }))
.sort((a, b) => cmp(a.id, b.id)), .sort((a, b) => cmp(a.id, b.id)),
@@ -735,7 +713,6 @@ export function createServerSession(
retained: load?.retainedMessages, retained: load?.retainedMessages,
removed: load?.removedMessages, removed: load?.removedMessages,
preserveUnfetched, preserveUnfetched,
compare: compareMessages,
}) })
batch(() => { batch(() => {
if (source) setData("session_message", sessionID, reconcile(source)) if (source) setData("session_message", sessionID, reconcile(source))
@@ -777,7 +754,7 @@ export function createServerSession(
try { try {
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load)) const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
const first = page.session.reduce<Message | undefined>( const first = page.session.reduce<Message | undefined>(
(oldest, message) => (!oldest || compareMessages(message, oldest) < 0 ? message : oldest), (oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
undefined, undefined,
) )
if (generations.get(sessionID) !== active) return if (generations.get(sessionID) !== active) return
@@ -827,15 +804,14 @@ export function createServerSession(
session: merge( session: merge(
page.session, page.session,
parents.map((parent) => parent.message), parents.map((parent) => parent.message),
).sort(compareMessages), ),
part: merge( part: merge(
page.part, page.part,
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })), parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
), ),
} }
const preserveUnfetched = const preserveUnfetched =
mode === "prepend" || mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
(!result.complete && (!first || ((message: Message) => compareMessages(message, first) < 0)))
applyMessagePage( applyMessagePage(
sessionID, sessionID,
result, result,
@@ -952,7 +928,7 @@ export function createServerSession(
.message({ sessionID, messageID }) .message({ sessionID, messageID })
.then((message) => { .then((message) => {
const current = data.session_message[sessionID] ?? [] const current = data.session_message[sessionID] ?? []
const messages = [...current.filter((item) => item.id !== message.id), message].sort(compareMessages) const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id))
projectV2({ sessionID, messages, touched: [message.id] }) projectV2({ sessionID, messages, touched: [message.id] })
}) })
.catch(() => {}) .catch(() => {})
@@ -1042,27 +1018,6 @@ export function createServerSession(
evict([sessionID]) evict([sessionID])
return 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": { case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] } const props = event.properties as { sessionID: string; todos: Todo[] }
setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) setData("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
@@ -1096,7 +1051,7 @@ export function createServerSession(
setData("message", info.sessionID, [info]) setData("message", info.sessionID, [info])
return return
} }
const result = Binary.search(messages, messageKey(info), messageKey) const result = Binary.search(messages, info.id, (message) => message.id)
if (result.found) setData("message", info.sessionID, result.index, reconcile(info)) if (result.found) setData("message", info.sessionID, result.index, reconcile(info))
if (!result.found) if (!result.found)
setData("message", info.sessionID, (value = []) => { setData("message", info.sessionID, (value = []) => {
@@ -1129,8 +1084,8 @@ export function createServerSession(
produce((draft) => { produce((draft) => {
const messages = draft.message[props.sessionID] const messages = draft.message[props.sessionID]
if (messages) { if (messages) {
const index = messages.findIndex((message) => message.id === props.messageID) const result = Binary.search(messages, props.messageID, (message) => message.id)
if (index >= 0) messages.splice(index, 1) if (result.found) messages.splice(result.index, 1)
} }
deleteMessageParts(draft, props.messageID) deleteMessageParts(draft, props.messageID)
}), }),
@@ -1142,7 +1097,7 @@ export function createServerSession(
if (SKIP_PARTS.has(part.type)) return if (SKIP_PARTS.has(part.type)) return
const messages = data.message[part.sessionID] const messages = data.message[part.sessionID]
const load = messageLoads.get(part.sessionID) const load = messageLoads.get(part.sessionID)
const missing = !messages?.some((message) => message.id === part.messageID) const missing = !messages || !Binary.search(messages, part.messageID, (message) => message.id).found
// Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan. // Outside a page load, accepting a part without its ordered parent event would create an unbounded orphan.
if ( if (
missing && missing &&
@@ -1386,8 +1341,7 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items) if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) 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("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData( setData(
"part_text_accum_delta", "part_text_accum_delta",
produce((draft) => { produce((draft) => {
@@ -1420,9 +1374,6 @@ export function createServerSession(
) )
return 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("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID))) setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
}, },
+2 -68
View File
@@ -1,26 +1,18 @@
import { describe, expect, test } from "bun:test" 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 { import type {
McpListInput, McpListInput,
McpResourceCatalogInput, McpResourceCatalogInput,
SessionApi, SessionApi,
SessionInfo, SessionInfo,
SessionListInput, SessionListInput,
OpenCodeEvent,
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { QueryClient } from "@tanstack/solid-query" import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
captureSessionMove,
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope" import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session" import { createServerSession } from "./server-session"
import { adaptServerEvent } from "./server-sdk"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
type McpApi = ServerApi["mcp"] type McpApi = ServerApi["mcp"]
@@ -110,52 +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" })
})
})
describe("pickDirectoriesToEvict", () => { describe("pickDirectoriesToEvict", () => {
test("keeps pinned stores and evicts idle stores", () => { test("keeps pinned stores and evicts idle stores", () => {
const now = 5_000 const now = 5_000
@@ -228,18 +174,6 @@ function sessionInfo(id: string) {
} as SessionInfo } 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", () => { describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => { test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) 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" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path" 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 { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error" import type { InitError } from "../pages/error"
import { ServerSDK, type ServerEvent } from "./server-sdk" import { ServerSDK } from "./server-sdk"
import { import {
bootstrapDirectory, bootstrapDirectory,
bootstrapGlobal, bootstrapGlobal,
@@ -46,32 +46,6 @@ import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry" import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope" import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index" 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 { persisted } from "@/utils/persist"
import type { ServerApi } from "@/utils/server" import type { ServerApi } from "@/utils/server"
import type { import type {
@@ -85,7 +59,6 @@ import type {
} from "@opencode-ai/client/promise" } from "@opencode-ai/client/promise"
import { toggleMcp } from "./global-sync/mcp" import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session" import { createServerSession, type ServerSession } from "./server-session"
import { decodeSessionMessages } from "./session-message-decoder"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -253,7 +226,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, {
protocol: serverSDK.protocol, protocol: serverSDK.protocol,
decodeMessages: decodeSessionMessages,
}) })
const queryOptionsApi = makeQueryOptionsApi( const queryOptionsApi = makeQueryOptionsApi(
serverSDK.scope, serverSDK.scope,
@@ -263,40 +235,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
serverSDK.protocol, serverSDK.protocol,
) )
const [providersEnabled, setProvidersEnabled] = createSignal(false)
const [backgroundEnabled, setBackgroundEnabled] = createSignal(false)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
{ ...queryOptionsApi.globalConfig(), enabled: backgroundEnabled() },
{ ...queryOptionsApi.providers(null), enabled: providersEnabled() },
{ ...queryOptionsApi.path(null), enabled: backgroundEnabled() },
],
})) }))
const activeSessionsQuery = useQuery(() => const activeSessionsQuery = useQuery(() =>
({ loadActiveSessionsQuery(serverSDK.scope, {
...loadActiveSessionsQuery(serverSDK.scope, { active: async () => {
active: async () => { if ((await serverSDK.protocol) === "v1") {
if ((await serverSDK.protocol) === "v1") { const statuses = (await serverSDK.client.session.status()).data ?? {}
const statuses = (await serverSDK.client.session.status()).data ?? {} seedActiveSessionStatuses(session, statuses)
seedActiveSessionStatuses(session, statuses) for (const sessionID of Object.keys(statuses)) {
for (const sessionID of Object.keys(statuses)) {
void session.resolve(sessionID).catch(() => undefined)
}
return Object.fromEntries(
Object.entries(statuses).flatMap(([sessionID, status]) =>
status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
),
)
}
const active = await serverSDK.api.session.active()
seedActiveSessionStatuses(session, active)
for (const sessionID of Object.keys(active)) {
void session.resolve(sessionID).catch(() => undefined) void session.resolve(sessionID).catch(() => undefined)
} }
return active return Object.fromEntries(
}, Object.entries(statuses).flatMap(([sessionID, status]) =>
}), status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]],
enabled: backgroundEnabled(), ),
)
}
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 bootingRoot = false
let eventFrame: number | undefined let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | 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(() => { onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer) 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[])) => { 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 unsub = serverSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const key = directoryKey(directory) const key = directoryKey(directory)
const event = e.details const event = e.details
const eventType: string = event.type const eventType: string = event.type
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
const moved = captureSessionMove(event, session.get)
if (event.current) session.applyV2(event.current) if (event.current) session.applyV2(event.current)
session.apply(event) 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") { if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") {
homeSessions.apply(event) homeSessions.apply(event)
} }
homeSessions.refresh(moved?.refresh ?? event.type) homeSessions.refresh(event.type)
if (eventType === "integration.connection.updated") void refreshProviders() if (eventType === "integration.connection.updated") void refreshProviders()
if (directory === "global") { if (directory === "global") {
@@ -671,6 +572,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
return 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") if (event.current?.type === "session.forked")
void session void session
.resolve(event.current.data.sessionID, { force: true }) .resolve(event.current.data.sessionID, { force: true })
@@ -777,15 +682,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
child: children.child, child: children.child,
peek: children.peek, peek: children.peek,
disableMcp: children.disableMcp, disableMcp: children.disableMcp,
enableProviders: children.enableProviders,
queryOptions: queryOptionsApi, queryOptions: queryOptionsApi,
loadProviders: () => setProvidersEnabled(true),
refreshProviders, refreshProviders,
// bootstrap, // bootstrap,
updateConfig: updateConfigMutation.mutateAsync, updateConfig: updateConfigMutation.mutateAsync,
project: projectApi, project: projectApi,
session, session,
reindexSession,
homeSessions, homeSessions,
mcp: { mcp: {
toggle: async (directory: string, name: string) => { toggle: async (directory: string, name: string) => {
@@ -1,83 +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"])
})
test("keeps a bounded server-wide Home index when directories are not specified", () => {
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("repo-old", "/repo", 1), session("repo-new", "/repo", 2), session("other", "/other", 3)],
cursor: {},
}
const result = decodeHomeSessionPage(new TextEncoder().encode(JSON.stringify(page)).buffer, { limit: 1 })
expect(result.data.map((item) => item.id).sort()).toEqual(["other", "repo-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 = options.directories ? new Set(options.directories.map(pathKey)) : undefined
return {
data: [...Map.groupBy(sessions, (session) => pathKey(session.directory))]
.filter(([directory]) => !directories || 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 {}
+1 -41
View File
@@ -3,10 +3,6 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
import { usePlatform } from "@/context/platform" 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 { export interface NotificationSettings {
agent: boolean agent: boolean
@@ -54,10 +50,6 @@ export interface Settings {
permissions: { permissions: {
autoApprove: boolean autoApprove: boolean
} }
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings notifications: NotificationSettings
sounds: SoundSettings sounds: SoundSettings
} }
@@ -214,10 +206,6 @@ const defaultSettings: Settings = {
permissions: { permissions: {
autoApprove: false, autoApprove: false,
}, },
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: { notifications: {
agent: true, agent: true,
permissions: true, permissions: true,
@@ -367,11 +355,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("general", "followup", "steer") setStore("general", "followup", "steer")
}) })
createEffect(() => {
if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return
setStore("general", "showFileTree", false)
})
return { return {
ready, ready,
get current() { get current() {
@@ -470,7 +453,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
}, },
}, },
visibility: { visibility: {
fileTree: createMemo(() => !newLayoutDesigns()), fileTree: visible(showFileTree),
search: visible(showSearch), search: visible(showSearch),
status: visible(showStatus), status: visible(showStatus),
customAgents: visible(showCustomAgents), customAgents: visible(showCustomAgents),
@@ -516,29 +499,6 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("permissions", "autoApprove", value) 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: { notifications: {
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) { setAgent(value: boolean) {
@@ -4,11 +4,11 @@ import { applyOptimisticAdd, applyOptimisticRemove, mergeOptimisticPage } from "
type Text = Extract<Part, { type: "text" }> type Text = Extract<Part, { type: "text" }>
const userMessage = (id: string, sessionID: string, created = 1): Message => ({ const userMessage = (id: string, sessionID: string): Message => ({
id, id,
sessionID, sessionID,
role: "user", role: "user",
time: { created }, time: { created: 1 },
agent: "assistant", agent: "assistant",
model: { providerID: "openai", modelID: "gpt" }, model: { providerID: "openai", modelID: "gpt" },
}) })
@@ -22,21 +22,21 @@ const textPart = (id: string, sessionID: string, messageID: string): Text => ({
}) })
describe("sync optimistic reducers", () => { describe("sync optimistic reducers", () => {
test("applyOptimisticAdd inserts by creation time", () => { test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const draft = { const draft = {
message: { [sessionID]: [userMessage("msg_z", sessionID, 1)] }, message: { [sessionID]: [userMessage("msg_2", sessionID)] },
part: {} as Record<string, Part[] | undefined>, part: {} as Record<string, Part[] | undefined>,
} }
applyOptimisticAdd(draft, { applyOptimisticAdd(draft, {
sessionID, sessionID,
message: userMessage("msg_a", sessionID, 2), message: userMessage("msg_1", sessionID),
parts: [textPart("prt_2", sessionID, "msg_a"), textPart("prt_1", sessionID, "msg_a")], parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
}) })
expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_z", "msg_a"]) expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(draft.part.msg_a?.map((x) => x.id)).toEqual(["prt_1", "prt_2"]) expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
}) })
test("applyOptimisticRemove removes message and part entries", () => { test("applyOptimisticRemove removes message and part entries", () => {
@@ -60,33 +60,19 @@ describe("sync optimistic reducers", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
{ {
session: [userMessage("msg_z", sessionID, 1)], session: [userMessage("msg_1", sessionID)],
part: [{ id: "msg_z", part: [textPart("prt_1", sessionID, "msg_z")] }], part: [{ id: "msg_1", part: [textPart("prt_1", sessionID, "msg_1")] }],
complete: true, complete: true,
}, },
[{ message: userMessage("msg_a", sessionID, 2), parts: [textPart("prt_2", sessionID, "msg_a")] }], [{ message: userMessage("msg_2", sessionID), parts: [textPart("prt_2", sessionID, "msg_2")] }],
) )
expect(page.session.map((x) => x.id)).toEqual(["msg_z", "msg_a"]) expect(page.session.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
expect(page.part.find((x) => x.id === "msg_a")?.part.map((x) => x.id)).toEqual(["prt_2"]) expect(page.part.find((x) => x.id === "msg_2")?.part.map((x) => x.id)).toEqual(["prt_2"])
expect(page.confirmed).toEqual([]) expect(page.confirmed).toEqual([])
expect(page.complete).toBe(true) expect(page.complete).toBe(true)
}) })
test("mergeOptimisticPage uses IDs only to break equal-time ties", () => {
const sessionID = "ses_1"
const page = mergeOptimisticPage(
{
session: [userMessage("msg_z", sessionID, 1)],
part: [],
complete: true,
},
[{ message: userMessage("msg_a", sessionID, 1), parts: [] }],
)
expect(page.session.map((message) => message.id)).toEqual(["msg_a", "msg_z"])
})
test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => { test("mergeOptimisticPage keeps missing optimistic parts until the server has them", () => {
const sessionID = "ses_1" const sessionID = "ses_1"
const page = mergeOptimisticPage( const page = mergeOptimisticPage(
+4 -5
View File
@@ -3,7 +3,6 @@ import { createMemo } from "solid-js"
import { useServerSync } from "./server-sync" import { useServerSync } from "./server-sync"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { messageKey } from "@/utils/session-message"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -68,7 +67,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
const confirmed: string[] = [] const confirmed: string[] = []
for (const item of items) { for (const item of items) {
const result = Binary.search(session, messageKey(item.message), messageKey) const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found const found = result.found
if (!found) session.splice(result.index, 0, item.message) if (!found) session.splice(result.index, 0, item.message)
@@ -93,7 +92,7 @@ export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[])
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) { export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID] const messages = draft.message[input.sessionID]
if (messages) { if (messages) {
const result = Binary.search(messages, messageKey(input.message), messageKey) const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message) messages.splice(result.index, 0, input.message)
} else { } else {
draft.message[input.sessionID] = [input.message] draft.message[input.sessionID] = [input.message]
@@ -104,8 +103,8 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) { export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID] const messages = draft.message[input.sessionID]
if (messages) { if (messages) {
const index = messages.findIndex((message) => message.id === input.messageID) const result = Binary.search(messages, input.messageID, (m) => m.id)
if (index >= 0) messages.splice(index, 1) if (result.found) messages.splice(result.index, 1)
} }
delete draft.part[input.messageID] delete draft.part[input.messageID]
} }
@@ -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 { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state" import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration" import { migrateTabs } from "./tab-migration"
import { nextTab, previousTab, rememberTab, type TabHistory } from "./tab-history"
export type SessionTab = { export type SessionTab = {
type: "session" type: "session"
@@ -75,7 +74,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const memory = createTabMemory(getOwner()) const memory = createTabMemory(getOwner())
const closing = new Set<string>() const closing = new Set<string>()
let history: TabHistory = { stack: [], index: -1 }
let recentWrite = 0 let recentWrite = 0
let recentValue: string | undefined let recentValue: string | undefined
@@ -150,21 +148,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigateTab = (tab: Tab) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
history = rememberTab(history, tabKey(tab))
setRecentKey(tabKey(tab)) setRecentKey(tabKey(tab))
navigate(href) 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 removeTab = (index: number) => {
const tab = store[index] const tab = store[index]
if (!tab) return if (!tab) return
@@ -190,11 +177,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
} }
const actions = { const actions = {
active() {
if (location.pathname === "/") return
const key = recentKey()
return store.find((tab) => tabKey(tab) === key)
},
addSessionTab: (tab: Omit<SessionTab, "type">) => { addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab } const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next)) const existing = store.find((item) => tabKey(item) === tabKey(next))
@@ -375,11 +357,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
select: navigateTab, select: navigateTab,
remember(tab: Tab) { remember(tab: Tab) {
const key = tabKey(tab) const key = tabKey(tab)
history = rememberTab(history, key)
if (recentKey() !== key) setRecentKey(key) if (recentKey() !== key) setRecentKey(key)
}, },
previous: () => moveHistory("previous"),
next: () => moveHistory("next"),
toggleHome(input: { home: boolean; current?: Tab }) { toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) { if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey()) const tab = store.find((tab) => tabKey(tab) === recentKey())
-21
View File
@@ -2,27 +2,6 @@ import { describe, expect, test } from "bun:test"
import { DESKTOP_MENU } from "./desktop-menu" import { DESKTOP_MENU } from "./desktop-menu"
describe("desktop menu", () => { describe("desktop menu", () => {
test("navigates between tabs", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && (item.command === "tab.prev" || item.command === "tab.next"),
)
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", () => { test("exports logs through the desktop command registry", () => {
const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter(
(item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs", (item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs",
-13
View File
@@ -237,19 +237,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
{ type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, { 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: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } },
{ type: "separator" }, { 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", type: "item",
labelKey: "desktop.menu.previousSession", labelKey: "desktop.menu.previousSession",
+25 -28
View File
@@ -3,7 +3,6 @@
import * as Sentry from "@sentry/solid" import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { loadInitialLocale } from "@/context/language"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { createBrowserDraftStore } from "@/utils/draft-store" import { createBrowserDraftStore } from "@/utils/draft-store"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
@@ -150,31 +149,29 @@ if (import.meta.env.VITE_SENTRY_DSN) {
} }
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
void loadInitialLocale().then((locale) => { const auth = authFromToken(new URLSearchParams(location.search).get("auth_token"))
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) clearAuthToken()
clearAuthToken() const server: ServerConnection.Http = {
const server: ServerConnection.Http = { type: "http",
type: "http", authToken: !!auth,
authToken: !!auth, http: {
http: { url: getCurrentUrl(),
url: getCurrentUrl(), ...auth,
...auth, },
}, }
} render(
render( () => (
() => ( <PlatformProvider value={platform}>
<PlatformProvider value={platform}> <AppBaseProviders>
<AppBaseProviders locale={locale}> <AppInterface
<AppInterface defaultServer={ServerConnection.Key.make(getDefaultUrl())}
defaultServer={ServerConnection.Key.make(getDefaultUrl())} canonicalLocalServer={ServerConnection.key(server)}
canonicalLocalServer={ServerConnection.key(server)} servers={[server]}
servers={[server]} disableHealthCheck
disableHealthCheck />
/> </AppBaseProviders>
</AppBaseProviders> </PlatformProvider>
</PlatformProvider> ),
), root,
root, )
)
})
} }
-6
View File
@@ -21,12 +21,6 @@ export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync() const serverSync = useServerSync()
const params = useParams() const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir)) const dir = () => (directory ? directory() : decode64(params.dir))
createEffect(() => {
const value = dir()
if (value) {
serverSync().enableProviders(value)
}
})
const providers = () => { const providers = () => {
const value = dir() const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined const projectStore = value ? serverSync().child(value)[0] : undefined
File diff suppressed because it is too large Load Diff
-13
View File
@@ -184,9 +184,6 @@ export const dict = {
"command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة", "command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة",
"command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"command.session.export": "تصدير الجلسة",
"command.session.export.description": "تصدير النص الكامل للجلسة بصيغة JSON",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder.home": "البحث في الأوامر والجلسات", "palette.search.placeholder.home": "البحث في الأوامر والجلسات",
"palette.empty": "لا توجد نتائج", "palette.empty": "لا توجد نتائج",
@@ -534,8 +531,6 @@ export const dict = {
"dialog.project.edit.worktree.startup": "برنامج نصي لبدء تشغيل مساحة العمل", "dialog.project.edit.worktree.startup": "برنامج نصي لبدء تشغيل مساحة العمل",
"dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).", "dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).",
"dialog.project.edit.worktree.startup.placeholder": "مثال: bun install", "dialog.project.edit.worktree.startup.placeholder": "مثال: bun install",
"dialog.usageExceeded.dontShowAgain": "عدم الإظهار مرة أخرى",
"context.breakdown.title": "تفصيل السياق", "context.breakdown.title": "تفصيل السياق",
"context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال المميزة. يشمل "أخرى" تعريفات الأدوات والأعباء الإضافية.', "context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال المميزة. يشمل "أخرى" تعريفات الأدوات والأعباء الإضافية.',
"context.breakdown.system": "النظام", "context.breakdown.system": "النظام",
@@ -545,8 +540,6 @@ export const dict = {
"context.breakdown.other": "أخرى", "context.breakdown.other": "أخرى",
"context.systemPrompt.title": "موجّه النظام", "context.systemPrompt.title": "موجّه النظام",
"context.rawMessages.title": "الرسائل الخام", "context.rawMessages.title": "الرسائل الخام",
"context.export.session": "تصدير الجلسة",
"context.stats.session": "جلسة", "context.stats.session": "جلسة",
"context.stats.messages": "رسائل", "context.stats.messages": "رسائل",
"context.stats.provider": "موفر", "context.stats.provider": "موفر",
@@ -613,11 +606,6 @@ export const dict = {
"toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!", "toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!",
"toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة", "toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة",
"toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة", "toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة",
"toast.session.export.success.title": "تم تصدير الجلسة",
"toast.session.export.success.description": "تم حفظ الجلسة في \u2068{{filename}}\u2069",
"toast.session.export.failed.title": "فشل تصدير الجلسة",
"toast.session.export.failed.description": "حدث خطأ أثناء تصدير الجلسة",
"toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}", "toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}",
"toast.update.title": "تحديث متاح", "toast.update.title": "تحديث متاح",
"toast.update.description": "يتوفر الآن إصدار جديد من OpenCode ({{version}}) للتثبيت.", "toast.update.description": "يتوفر الآن إصدار جديد من OpenCode ({{version}}) للتثبيت.",
@@ -805,7 +793,6 @@ export const dict = {
"common.moreOptions": "مزيد من الخيارات", "common.moreOptions": "مزيد من الخيارات",
"common.learnMore": "اطّلع على المزيد", "common.learnMore": "اطّلع على المزيد",
"common.rename": "إعادة تسمية", "common.rename": "إعادة تسمية",
"common.export": "تصدير",
"common.reset": "إعادة تعيين", "common.reset": "إعادة تعيين",
"common.archive": "أرشفة", "common.archive": "أرشفة",
"common.delete": "حذف", "common.delete": "حذف",
-13
View File
@@ -180,9 +180,6 @@ export const dict = {
"command.session.share.description": "Bu sessiyanı paylaş və URL-ni buferə kopyala", "command.session.share.description": "Bu sessiyanı paylaş və URL-ni buferə kopyala",
"command.session.unshare": "Sessiyanın paylaşımını dayandır", "command.session.unshare": "Sessiyanın paylaşımını dayandır",
"command.session.unshare.description": "Bu sessiyanın paylaşımını dayandır", "command.session.unshare.description": "Bu sessiyanın paylaşımını dayandır",
"command.session.export": "Sessiyanı ixrac et",
"command.session.export.description": "Sessiyanın tam transkriptini JSON formatında ixrac et",
"palette.search.placeholder": "Fayl, əmr və sessiya axtar", "palette.search.placeholder": "Fayl, əmr və sessiya axtar",
"palette.search.placeholder.home": "Əmr və sessiyaları axtar", "palette.search.placeholder.home": "Əmr və sessiyaları axtar",
"palette.empty": "Nəticə tapılmadı", "palette.empty": "Nəticə tapılmadı",
@@ -544,8 +541,6 @@ export const dict = {
"dialog.releaseNotes.action.next": "Növbəti", "dialog.releaseNotes.action.next": "Növbəti",
"dialog.releaseNotes.action.hideFuture": "Gələcəkdə göstərmə", "dialog.releaseNotes.action.hideFuture": "Gələcəkdə göstərmə",
"dialog.releaseNotes.media.alt": "Buraxılış önbaxışı", "dialog.releaseNotes.media.alt": "Buraxılış önbaxışı",
"dialog.usageExceeded.dontShowAgain": "Bir daha göstərmə",
"context.breakdown.title": "Kontekst bölgüsü", "context.breakdown.title": "Kontekst bölgüsü",
"context.breakdown.note": 'Giriş tokenlərinin təxmini bölgüsü. "Digər" alət təriflərini və əlavə yükü əhatə edir.', "context.breakdown.note": 'Giriş tokenlərinin təxmini bölgüsü. "Digər" alət təriflərini və əlavə yükü əhatə edir.',
"context.breakdown.system": "Sistem", "context.breakdown.system": "Sistem",
@@ -555,8 +550,6 @@ export const dict = {
"context.breakdown.other": "Digər", "context.breakdown.other": "Digər",
"context.systemPrompt.title": "Sistem promptu", "context.systemPrompt.title": "Sistem promptu",
"context.rawMessages.title": "Xam mesajlar", "context.rawMessages.title": "Xam mesajlar",
"context.export.session": "Sessiyanı ixrac et",
"context.stats.session": "Sessiya", "context.stats.session": "Sessiya",
"context.stats.messages": "Mesajlar", "context.stats.messages": "Mesajlar",
"context.stats.provider": "Provayder", "context.stats.provider": "Provayder",
@@ -623,11 +616,6 @@ export const dict = {
"toast.session.unshare.success.description": "Sessiyanın paylaşımı uğurla dayandırıldı!", "toast.session.unshare.success.description": "Sessiyanın paylaşımı uğurla dayandırıldı!",
"toast.session.unshare.failed.title": "Sessiyanın paylaşımı dayandırıla bilmədi", "toast.session.unshare.failed.title": "Sessiyanın paylaşımı dayandırıla bilmədi",
"toast.session.unshare.failed.description": "Sessiyanın paylaşımını dayandırarkən xəta baş verdi", "toast.session.unshare.failed.description": "Sessiyanın paylaşımını dayandırarkən xəta baş verdi",
"toast.session.export.success.title": "Sessiya ixrac edildi",
"toast.session.export.success.description": "Sessiya {{filename}} faylına saxlanıldı",
"toast.session.export.failed.title": "Sessiya ixrac edilə bilmədi",
"toast.session.export.failed.description": "Sessiyanı ixrac edərkən xəta baş verdi",
"toast.session.listFailed.title": "{{project}} üçün sessiyalar yüklənə bilmədi", "toast.session.listFailed.title": "{{project}} üçün sessiyalar yüklənə bilmədi",
"toast.project.reloadFailed.title": "{{project}} yenidən yüklənə bilmədi", "toast.project.reloadFailed.title": "{{project}} yenidən yüklənə bilmədi",
"toast.update.title": "Yeniləmə mövcuddur", "toast.update.title": "Yeniləmə mövcuddur",
@@ -844,7 +832,6 @@ export const dict = {
"common.moreOptions": "Daha çox seçim", "common.moreOptions": "Daha çox seçim",
"common.learnMore": "Ətraflı öyrən", "common.learnMore": "Ətraflı öyrən",
"common.rename": "Adını dəyiş", "common.rename": "Adını dəyiş",
"common.export": "İxrac et",
"common.reset": "Sıfırla", "common.reset": "Sıfırla",
"common.archive": "Arxivlə", "common.archive": "Arxivlə",
"common.delete": "Sil", "common.delete": "Sil",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-13
View File
@@ -186,9 +186,6 @@ export const dict = {
"command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência", "command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência",
"command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão",
"command.session.export": "Exportar sessão",
"command.session.export.description": "Exportar a transcrição completa da sessão como JSON",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões", "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.search.placeholder.home": "Buscar comandos e sessões", "palette.search.placeholder.home": "Buscar comandos e sessões",
"palette.empty": "Nenhum resultado encontrado", "palette.empty": "Nenhum resultado encontrado",
@@ -537,8 +534,6 @@ export const dict = {
"dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho", "dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho",
"dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).", "dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "Ex.: bun install", "dialog.project.edit.worktree.startup.placeholder": "Ex.: bun install",
"dialog.usageExceeded.dontShowAgain": "Não mostrar novamente",
"context.breakdown.title": "Detalhamento do contexto", "context.breakdown.title": "Detalhamento do contexto",
"context.breakdown.note": "context.breakdown.note":
'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e sobrecarga.', 'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e sobrecarga.',
@@ -549,8 +544,6 @@ export const dict = {
"context.breakdown.other": "Outros", "context.breakdown.other": "Outros",
"context.systemPrompt.title": "Prompt do sistema", "context.systemPrompt.title": "Prompt do sistema",
"context.rawMessages.title": "Mensagens brutas", "context.rawMessages.title": "Mensagens brutas",
"context.export.session": "Exportar sessão",
"context.stats.session": "Sessão", "context.stats.session": "Sessão",
"context.stats.messages": "Mensagens", "context.stats.messages": "Mensagens",
"context.stats.provider": "Provedor", "context.stats.provider": "Provedor",
@@ -617,11 +610,6 @@ export const dict = {
"toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!", "toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!",
"toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão", "toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão",
"toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão", "toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão",
"toast.session.export.success.title": "Sessão exportada",
"toast.session.export.success.description": "Sessão salva em {{filename}}",
"toast.session.export.failed.title": "Falha ao exportar sessão",
"toast.session.export.failed.description": "Ocorreu um erro ao exportar a sessão",
"toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}", "toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}",
"toast.update.title": "Atualização disponível", "toast.update.title": "Atualização disponível",
"toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.", "toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.",
@@ -808,7 +796,6 @@ export const dict = {
"common.moreOptions": "Mais opções", "common.moreOptions": "Mais opções",
"common.learnMore": "Saiba mais", "common.learnMore": "Saiba mais",
"common.rename": "Renomear", "common.rename": "Renomear",
"common.export": "Exportar",
"common.reset": "Redefinir", "common.reset": "Redefinir",
"common.archive": "Arquivar", "common.archive": "Arquivar",
"common.delete": "Excluir", "common.delete": "Excluir",
-13
View File
@@ -193,9 +193,6 @@ export const dict = {
"command.session.unshare": "Ukini dijeljenje sesije", "command.session.unshare": "Ukini dijeljenje sesije",
"command.session.unshare.description": "Zaustavi dijeljenje ove sesije", "command.session.unshare.description": "Zaustavi dijeljenje ove sesije",
"command.session.export": "Izvezi sesiju",
"command.session.export.description": "Izvezi cijeli zapis sesije u JSON formatu",
"palette.search.placeholder": "Pretraži datoteke, komande i sesije", "palette.search.placeholder": "Pretraži datoteke, komande i sesije",
"palette.search.placeholder.home": "Pretraži komande i sesije", "palette.search.placeholder.home": "Pretraži komande i sesije",
"palette.empty": "Nema rezultata", "palette.empty": "Nema rezultata",
@@ -568,8 +565,6 @@ export const dict = {
"dialog.project.edit.worktree.startup.description": "Pokreće se nakon kreiranja novog radnog prostora (worktree).", "dialog.project.edit.worktree.startup.description": "Pokreće se nakon kreiranja novog radnog prostora (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "npr. bun install", "dialog.project.edit.worktree.startup.placeholder": "npr. bun install",
"dialog.usageExceeded.dontShowAgain": "Nemoj više prikazivati",
"context.breakdown.title": "Razlaganje konteksta", "context.breakdown.title": "Razlaganje konteksta",
"context.breakdown.note": "context.breakdown.note":
'Približna raspodjela ulaznih tokena. "Ostalo" uključuje definicije alata i dodatno opterećenje.', 'Približna raspodjela ulaznih tokena. "Ostalo" uključuje definicije alata i dodatno opterećenje.',
@@ -582,8 +577,6 @@ export const dict = {
"context.systemPrompt.title": "Sistemski prompt", "context.systemPrompt.title": "Sistemski prompt",
"context.rawMessages.title": "Sirove poruke", "context.rawMessages.title": "Sirove poruke",
"context.export.session": "Izvezi sesiju",
"context.stats.session": "Sesija", "context.stats.session": "Sesija",
"context.stats.messages": "Poruke", "context.stats.messages": "Poruke",
"context.stats.provider": "Provajder", "context.stats.provider": "Provajder",
@@ -662,11 +655,6 @@ export const dict = {
"toast.session.unshare.failed.title": "Neuspjelo ukidanje dijeljenja", "toast.session.unshare.failed.title": "Neuspjelo ukidanje dijeljenja",
"toast.session.unshare.failed.description": "Došlo je do greške prilikom ukidanja dijeljenja", "toast.session.unshare.failed.description": "Došlo je do greške prilikom ukidanja dijeljenja",
"toast.session.export.success.title": "Sesija izvezena",
"toast.session.export.success.description": "Sesija je sačuvana kao {{filename}}",
"toast.session.export.failed.title": "Izvoz sesije nije uspio",
"toast.session.export.failed.description": "Došlo je do greške prilikom izvoza sesije",
"toast.session.listFailed.title": "Neuspjelo učitavanje sesija za {{project}}", "toast.session.listFailed.title": "Neuspjelo učitavanje sesija za {{project}}",
"toast.update.title": "Dostupno ažuriranje", "toast.update.title": "Dostupno ažuriranje",
@@ -868,7 +856,6 @@ export const dict = {
"common.moreOptions": "Više opcija", "common.moreOptions": "Više opcija",
"common.learnMore": "Saznaj više", "common.learnMore": "Saznaj više",
"common.rename": "Preimenuj", "common.rename": "Preimenuj",
"common.export": "Izvezi",
"common.reset": "Vrati na početno stanje", "common.reset": "Vrati na početno stanje",
"common.archive": "Arhiviraj", "common.archive": "Arhiviraj",
"common.delete": "Izbriši", "common.delete": "Izbriši",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-13
View File
@@ -92,9 +92,6 @@ export const dict = {
"command.session.unshare": "Stop deling af session", "command.session.unshare": "Stop deling af session",
"command.session.unshare.description": "Stop med at dele denne session", "command.session.unshare.description": "Stop med at dele denne session",
"command.session.export": "Eksportér session",
"command.session.export.description": "Eksportér hele sessionsudskriften som JSON",
"palette.search.placeholder": "Søg i filer, kommandoer og sessioner", "palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.search.placeholder.home": "Søg i kommandoer og sessioner", "palette.search.placeholder.home": "Søg i kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet", "palette.empty": "Ingen resultater fundet",
@@ -449,8 +446,6 @@ export const dict = {
"dialog.project.edit.worktree.startup": "Opstartsscript for arbejdsområde", "dialog.project.edit.worktree.startup": "Opstartsscript for arbejdsområde",
"dialog.project.edit.worktree.startup.description": "Køres efter oprettelse af et nyt arbejdsområde (worktree).", "dialog.project.edit.worktree.startup.description": "Køres efter oprettelse af et nyt arbejdsområde (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "f.eks. bun install", "dialog.project.edit.worktree.startup.placeholder": "f.eks. bun install",
"dialog.usageExceeded.dontShowAgain": "Vis ikke igen",
"context.breakdown.title": "Kontekstfordeling", "context.breakdown.title": "Kontekstfordeling",
"context.breakdown.note": "context.breakdown.note":
'Omtrentlig fordeling af input-tokens. "Andre" inkluderer værktøjsdefinitioner og overhead.', 'Omtrentlig fordeling af input-tokens. "Andre" inkluderer værktøjsdefinitioner og overhead.',
@@ -463,8 +458,6 @@ export const dict = {
"context.systemPrompt.title": "Systemprompt", "context.systemPrompt.title": "Systemprompt",
"context.rawMessages.title": "Rå beskeder", "context.rawMessages.title": "Rå beskeder",
"context.export.session": "Eksportér session",
"context.stats.session": "Session", "context.stats.session": "Session",
"context.stats.messages": "Beskeder", "context.stats.messages": "Beskeder",
"context.stats.provider": "Udbyder", "context.stats.provider": "Udbyder",
@@ -542,11 +535,6 @@ export const dict = {
"toast.session.unshare.failed.title": "Kunne ikke stoppe deling af session", "toast.session.unshare.failed.title": "Kunne ikke stoppe deling af session",
"toast.session.unshare.failed.description": "Der opstod en fejl under stop af sessionsdeling", "toast.session.unshare.failed.description": "Der opstod en fejl under stop af sessionsdeling",
"toast.session.export.success.title": "Session eksporteret",
"toast.session.export.success.description": "Sessionen blev gemt i {{filename}}",
"toast.session.export.failed.title": "Kunne ikke eksportere session",
"toast.session.export.failed.description": "Der opstod en fejl under eksport af sessionen",
"toast.session.listFailed.title": "Kunne ikke indlæse sessioner for {{project}}", "toast.session.listFailed.title": "Kunne ikke indlæse sessioner for {{project}}",
"toast.update.title": "Opdatering tilgængelig", "toast.update.title": "Opdatering tilgængelig",
@@ -745,7 +733,6 @@ export const dict = {
"common.moreOptions": "Flere muligheder", "common.moreOptions": "Flere muligheder",
"common.learnMore": "Lær mere", "common.learnMore": "Lær mere",
"common.rename": "Omdøb", "common.rename": "Omdøb",
"common.export": "Eksportér",
"common.reset": "Nulstil", "common.reset": "Nulstil",
"common.archive": "Arkivér", "common.archive": "Arkivér",
"common.delete": "Slet", "common.delete": "Slet",
-13
View File
@@ -89,9 +89,6 @@ export const dict = {
"command.session.share.description": "Diese Sitzung teilen und URL in die Zwischenablage kopieren", "command.session.share.description": "Diese Sitzung teilen und URL in die Zwischenablage kopieren",
"command.session.unshare": "Teilen der Sitzung aufheben", "command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden", "command.session.unshare.description": "Teilen dieser Sitzung beenden",
"command.session.export": "Sitzung exportieren",
"command.session.export.description": "Das vollständige Transkript der Sitzung als JSON exportieren",
"palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen", "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen", "palette.search.placeholder.home": "Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden", "palette.empty": "Keine Ergebnisse gefunden",
@@ -428,8 +425,6 @@ export const dict = {
"dialog.project.edit.worktree.startup.description": "dialog.project.edit.worktree.startup.description":
"Wird nach dem Erstellen eines neuen Arbeitsbereichs (Worktree) ausgeführt.", "Wird nach dem Erstellen eines neuen Arbeitsbereichs (Worktree) ausgeführt.",
"dialog.project.edit.worktree.startup.placeholder": "z. B. bun install", "dialog.project.edit.worktree.startup.placeholder": "z. B. bun install",
"dialog.usageExceeded.dontShowAgain": "Nicht mehr anzeigen",
"context.breakdown.title": "Kontext-Aufschlüsselung", "context.breakdown.title": "Kontext-Aufschlüsselung",
"context.breakdown.note": "context.breakdown.note":
'Ungefähre Aufschlüsselung der Eingabe-Token. "Andere" beinhaltet Werkzeugdefinitionen und Overhead.', 'Ungefähre Aufschlüsselung der Eingabe-Token. "Andere" beinhaltet Werkzeugdefinitionen und Overhead.',
@@ -440,8 +435,6 @@ export const dict = {
"context.breakdown.other": "Andere", "context.breakdown.other": "Andere",
"context.systemPrompt.title": "System-Prompt", "context.systemPrompt.title": "System-Prompt",
"context.rawMessages.title": "Rohdaten der Nachrichten", "context.rawMessages.title": "Rohdaten der Nachrichten",
"context.export.session": "Sitzung exportieren",
"context.stats.session": "Sitzung", "context.stats.session": "Sitzung",
"context.stats.messages": "Nachrichten", "context.stats.messages": "Nachrichten",
"context.stats.provider": "Anbieter", "context.stats.provider": "Anbieter",
@@ -508,11 +501,6 @@ export const dict = {
"toast.session.unshare.success.description": "Teilen der Sitzung erfolgreich aufgehoben!", "toast.session.unshare.success.description": "Teilen der Sitzung erfolgreich aufgehoben!",
"toast.session.unshare.failed.title": "Aufheben des Teilens fehlgeschlagen", "toast.session.unshare.failed.title": "Aufheben des Teilens fehlgeschlagen",
"toast.session.unshare.failed.description": "Beim Aufheben des Teilens ist ein Fehler aufgetreten", "toast.session.unshare.failed.description": "Beim Aufheben des Teilens ist ein Fehler aufgetreten",
"toast.session.export.success.title": "Sitzung exportiert",
"toast.session.export.success.description": "Sitzung unter {{filename}} gespeichert",
"toast.session.export.failed.title": "Sitzung konnte nicht exportiert werden",
"toast.session.export.failed.description": "Beim Exportieren der Sitzung ist ein Fehler aufgetreten",
"toast.session.listFailed.title": "Sitzungen für {{project}} konnten nicht geladen werden", "toast.session.listFailed.title": "Sitzungen für {{project}} konnten nicht geladen werden",
"toast.update.title": "Update verfügbar", "toast.update.title": "Update verfügbar",
"toast.update.description": "Eine neue Version von OpenCode ({{version}}) ist zur Installation verfügbar.", "toast.update.description": "Eine neue Version von OpenCode ({{version}}) ist zur Installation verfügbar.",
@@ -698,7 +686,6 @@ export const dict = {
"common.moreOptions": "Weitere Optionen", "common.moreOptions": "Weitere Optionen",
"common.learnMore": "Mehr erfahren", "common.learnMore": "Mehr erfahren",
"common.rename": "Umbenennen", "common.rename": "Umbenennen",
"common.export": "Exportieren",
"common.reset": "Zurücksetzen", "common.reset": "Zurücksetzen",
"common.archive": "Archivieren", "common.archive": "Archivieren",
"common.delete": "Löschen", "common.delete": "Löschen",
@@ -3,83 +3,12 @@ import {
createDesktopNativeBundle, createDesktopNativeBundle,
DESKTOP_NATIVE_ENGLISH, DESKTOP_NATIVE_ENGLISH,
DESKTOP_NATIVE_KEYS, DESKTOP_NATIVE_KEYS,
DESKTOP_NATIVE_LABELS,
DESKTOP_NATIVE_LOCALES,
DESKTOP_NATIVE_LOCALE_TAGS,
detectDesktopNativeLocale,
DESKTOP_NATIVE_MAX_PAYLOAD_BYTES, DESKTOP_NATIVE_MAX_PAYLOAD_BYTES,
formatDesktopNativeMessage, formatDesktopNativeMessage,
parseDesktopNativeBundle, parseDesktopNativeBundle,
} from "./desktop-native" } from "./desktop-native"
describe("desktop native translations", () => { describe("desktop native translations", () => {
test("uses native language names independent of the active locale", () => {
expect(DESKTOP_NATIVE_LOCALES.map((locale) => DESKTOP_NATIVE_LABELS[locale])).toEqual([
"English",
"简体中文",
"繁體中文",
"한국어",
"Deutsch",
"Español",
"Français",
"Dansk",
"日本語",
"Polski",
"Русский",
"Українська",
"Bosanski",
"العربية",
"Norsk",
"Português (Brasil)",
"ไทย",
"Türkçe",
"हिन्दी",
"Nederlands",
"Bahasa Indonesia",
"Tiếng Việt",
"Italiano",
"اردو",
"پنجابی",
"Azərbaycanca",
"Suomi",
"Svenska",
"አማርኛ",
"Български",
"বাংলা",
"Català",
"Čeština",
"ދިވެހި",
"རྫོང་ཁ",
"Ελληνικά",
"Eesti",
"فارسی",
"Føroyskt",
"Hrvatski",
"Magyar",
"Հայերեն",
"Íslenska",
"ქართული",
"ខ្មែរ",
"ລາວ",
"Lietuvių",
"Latviešu",
"Македонски",
"Монгол",
"Bahasa Melayu",
"မြန်မာ",
"नेपाली",
"Română",
"සිංහල",
"Slovenčina",
"Slovenščina",
"Shqip",
"Српски",
"Тоҷикӣ",
"Türkmençe",
"Oʻzbekcha",
])
})
test("accepts the exact typed bundle", () => { test("accepts the exact typed bundle", () => {
const bundle = createDesktopNativeBundle("en", (key) => DESKTOP_NATIVE_ENGLISH[key]) const bundle = createDesktopNativeBundle("en", (key) => DESKTOP_NATIVE_ENGLISH[key])
expect(parseDesktopNativeBundle(bundle)).toEqual(bundle) expect(parseDesktopNativeBundle(bundle)).toEqual(bundle)
@@ -110,42 +39,3 @@ describe("desktop native translations", () => {
expect(formatDesktopNativeMessage("{{known}} {{unknown}}", { known: "yes" })).toBe("yes {{unknown}}") expect(formatDesktopNativeMessage("{{known}} {{unknown}}", { known: "yes" })).toBe("yes {{unknown}}")
}) })
}) })
describe("desktop native locale detection", () => {
test("follows preference order and skips invalid or unsupported tags", () => {
expect(detectDesktopNativeLocale(["not_a_locale", "fr-FR"])).toBe("fr")
expect(detectDesktopNativeLocale(["eo", "de-DE"])).toBe("de")
})
test("uses Unicode likely subtags for script-sensitive bundles", () => {
expect(detectDesktopNativeLocale(["zh-TW"])).toBe("zht")
expect(detectDesktopNativeLocale(["zh-SG"])).toBe("zh")
expect(detectDesktopNativeLocale(["pa-PK"])).toBe("pa")
expect(detectDesktopNativeLocale(["pa-IN", "fr"])).toBe("fr")
expect(detectDesktopNativeLocale(["az-Cyrl", "de"])).toBe("de")
expect(detectDesktopNativeLocale(["sr-Cyrl"])).toBe("sr")
expect(detectDesktopNativeLocale(["sr-Latn", "en"])).toBe("en")
expect(detectDesktopNativeLocale(["uz-Latn"])).toBe("uz")
})
test("recognizes Norwegian language tags", () => {
expect(detectDesktopNativeLocale(["no"])).toBe("no")
expect(detectDesktopNativeLocale(["nb-NO"])).toBe("no")
expect(detectDesktopNativeLocale(["nn-NO"])).toBe("no")
})
})
describe("desktop native ICU data", () => {
test("accepts every locale in standard Intl formatters", () => {
for (const locale of DESKTOP_NATIVE_LOCALES) {
const tag = DESKTOP_NATIVE_LOCALE_TAGS[locale]
expect(() => new Intl.Locale(tag), `${locale} locale`).not.toThrow()
expect(() => new Intl.NumberFormat(tag), `${locale} number`).not.toThrow()
expect(() => new Intl.DateTimeFormat(tag), `${locale} date`).not.toThrow()
expect(() => new Intl.PluralRules(tag), `${locale} plural`).not.toThrow()
expect(() => new Intl.ListFormat(tag), `${locale} list`).not.toThrow()
expect(() => new Intl.DisplayNames(tag, { type: "language" }), `${locale} names`).not.toThrow()
expect(() => new Intl.Segmenter(tag), `${locale} segmenter`).not.toThrow()
}
})
})
-192
View File
@@ -27,200 +27,10 @@ export const DESKTOP_NATIVE_LOCALES = [
"az", "az",
"fi", "fi",
"sv", "sv",
"am",
"bg",
"bn",
"ca",
"cs",
"dv",
"dz",
"el",
"et",
"fa",
"fo",
"hr",
"hu",
"hy",
"is",
"ka",
"km",
"lo",
"lt",
"lv",
"mk",
"mn",
"ms",
"my",
"ne",
"ro",
"si",
"sk",
"sl",
"sq",
"sr",
"tg",
"tk",
"uz",
] as const ] as const
export type DesktopNativeLocale = (typeof DESKTOP_NATIVE_LOCALES)[number] export type DesktopNativeLocale = (typeof DESKTOP_NATIVE_LOCALES)[number]
export const DESKTOP_NATIVE_LABELS: Record<DesktopNativeLocale, string> = {
en: "English",
zh: "简体中文",
zht: "繁體中文",
ko: "한국어",
de: "Deutsch",
es: "Español",
fr: "Français",
da: "Dansk",
ja: "日本語",
pl: "Polski",
ru: "Русский",
uk: "Українська",
bs: "Bosanski",
ar: "العربية",
no: "Norsk",
br: "Português (Brasil)",
th: "ไทย",
tr: "Türkçe",
hi: "हिन्दी",
nl: "Nederlands",
id: "Bahasa Indonesia",
vi: "Tiếng Việt",
it: "Italiano",
ur: "اردو",
pa: "پنجابی",
az: "Azərbaycanca",
fi: "Suomi",
sv: "Svenska",
am: "አማርኛ",
bg: "Български",
bn: "বাংলা",
ca: "Català",
cs: "Čeština",
dv: "ދިވެހި",
dz: "རྫོང་ཁ",
el: "Ελληνικά",
et: "Eesti",
fa: "فارسی",
fo: "Føroyskt",
hr: "Hrvatski",
hu: "Magyar",
hy: "Հայերեն",
is: "Íslenska",
ka: "ქართული",
km: "ខ្មែរ",
lo: "ລາວ",
lt: "Lietuvių",
lv: "Latviešu",
mk: "Македонски",
mn: "Монгол",
ms: "Bahasa Melayu",
my: "မြန်မာ",
ne: "नेपाली",
ro: "Română",
si: "සිංහල",
sk: "Slovenčina",
sl: "Slovenščina",
sq: "Shqip",
sr: "Српски",
tg: "Тоҷикӣ",
tk: "Türkmençe",
uz: "Oʻzbekcha",
}
export const DESKTOP_NATIVE_LOCALE_TAGS: Record<DesktopNativeLocale, string> = {
en: "en",
zh: "zh-Hans",
zht: "zh-Hant",
ko: "ko",
de: "de",
es: "es",
fr: "fr",
da: "da",
ja: "ja",
pl: "pl",
ru: "ru",
uk: "uk",
bs: "bs",
ar: "ar",
no: "nb-NO",
br: "pt-BR",
th: "th",
tr: "tr",
hi: "hi-IN",
nl: "nl-NL",
id: "id-ID",
vi: "vi-VN",
it: "it-IT",
ur: "ur-PK",
pa: "pa-Arab-PK",
az: "az-Latn-AZ",
fi: "fi-FI",
sv: "sv-SE",
am: "am-ET",
bg: "bg-BG",
bn: "bn-BD",
ca: "ca-AD",
cs: "cs-CZ",
dv: "dv-MV",
dz: "dz-BT",
el: "el-GR",
et: "et-EE",
fa: "fa-IR",
fo: "fo-FO",
hr: "hr-HR",
hu: "hu-HU",
hy: "hy-AM",
is: "is-IS",
ka: "ka-GE",
km: "km-KH",
lo: "lo-LA",
lt: "lt-LT",
lv: "lv-LV",
mk: "mk-MK",
mn: "mn-MN",
ms: "ms-MY",
my: "my-MM",
ne: "ne-NP",
ro: "ro-RO",
si: "si-LK",
sk: "sk-SK",
sl: "sl-SI",
sq: "sq-AL",
sr: "sr-Cyrl-RS",
tg: "tg-Cyrl-TJ",
tk: "tk-Latn-TM",
uz: "uz-Latn-UZ",
}
export function detectDesktopNativeLocale(languages: readonly string[]): DesktopNativeLocale {
for (const language of languages) {
const source = locale(language)
if (!source) continue
if (["no", "nb", "nn"].includes(source.language)) return "no"
const match = DESKTOP_NATIVE_LOCALES.find((candidate) => {
const target = locale(DESKTOP_NATIVE_LOCALE_TAGS[candidate])
return target?.language === source.language && target.script === source.script
})
if (match) return match
}
return "en"
}
export function desktopNativePluralCategories(locale: DesktopNativeLocale) {
return new Intl.PluralRules(DESKTOP_NATIVE_LOCALE_TAGS[locale]).resolvedOptions().pluralCategories
}
function locale(value: string) {
try {
return new Intl.Locale(value).maximize()
} catch {
return undefined
}
}
export const DESKTOP_NATIVE_ENGLISH = { export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.app": "OpenCode", "desktop.menu.app": "OpenCode",
"desktop.menu.file": "File", "desktop.menu.file": "File",
@@ -256,8 +66,6 @@ export const DESKTOP_NATIVE_ENGLISH = {
"desktop.menu.toggleFullScreen": "Toggle Full Screen", "desktop.menu.toggleFullScreen": "Toggle Full Screen",
"desktop.menu.back": "Back", "desktop.menu.back": "Back",
"desktop.menu.forward": "Forward", "desktop.menu.forward": "Forward",
"desktop.menu.previousTab": "Previous Tab",
"desktop.menu.nextTab": "Next Tab",
"desktop.menu.previousSession": "Previous Session", "desktop.menu.previousSession": "Previous Session",
"desktop.menu.nextSession": "Next Session", "desktop.menu.nextSession": "Next Session",
"desktop.menu.previousProject": "Previous Project", "desktop.menu.previousProject": "Previous Project",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-50
View File
@@ -95,8 +95,6 @@ export const dict = {
"command.session.share.description": "Share this session and copy the URL to clipboard", "command.session.share.description": "Share this session and copy the URL to clipboard",
"command.session.unshare": "Unshare session", "command.session.unshare": "Unshare session",
"command.session.unshare.description": "Stop sharing this session", "command.session.unshare.description": "Stop sharing this session",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder": "Search files, commands, and sessions",
"palette.search.placeholder.home": "Search commands and sessions", "palette.search.placeholder.home": "Search commands and sessions",
@@ -480,7 +478,6 @@ export const dict = {
"dialog.releaseNotes.action.next": "Next", "dialog.releaseNotes.action.next": "Next",
"dialog.releaseNotes.action.hideFuture": "Don't show these in the future", "dialog.releaseNotes.action.hideFuture": "Don't show these in the future",
"dialog.releaseNotes.media.alt": "Release preview", "dialog.releaseNotes.media.alt": "Release preview",
"dialog.usageExceeded.dontShowAgain": "Don't show again",
"context.breakdown.title": "Context Breakdown", "context.breakdown.title": "Context Breakdown",
"context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.', "context.breakdown.note": 'Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.',
@@ -492,7 +489,6 @@ export const dict = {
"context.systemPrompt.title": "System Prompt", "context.systemPrompt.title": "System Prompt",
"context.rawMessages.title": "Raw messages", "context.rawMessages.title": "Raw messages",
"context.export.session": "Export session",
"context.stats.session": "Session", "context.stats.session": "Session",
"context.stats.messages": "Messages", "context.stats.messages": "Messages",
@@ -572,11 +568,6 @@ export const dict = {
"toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.title": "Failed to unshare session",
"toast.session.unshare.failed.description": "An error occurred while unsharing the session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session",
"toast.session.export.success.title": "Session exported",
"toast.session.export.success.description": "Saved session to {{filename}}",
"toast.session.export.failed.title": "Failed to export session",
"toast.session.export.failed.description": "An error occurred while exporting the session",
"toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.session.listFailed.title": "Failed to load sessions for {{project}}",
"toast.project.reloadFailed.title": "Failed to reload {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}",
@@ -811,7 +802,6 @@ export const dict = {
"common.moreOptions": "More options", "common.moreOptions": "More options",
"common.learnMore": "Learn more", "common.learnMore": "Learn more",
"common.rename": "Rename", "common.rename": "Rename",
"common.export": "Export",
"common.reset": "Reset", "common.reset": "Reset",
"common.archive": "Archive", "common.archive": "Archive",
"common.delete": "Delete", "common.delete": "Delete",
@@ -1123,46 +1113,6 @@ export const dict = {
"session.delete.button": "Delete session", "session.delete.button": "Delete session",
"workspace.new": "New workspace", "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.menu.title": "Move session to",
"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",
"session.summary.title": "Session details",
"session.summary.noBranch": "No branch",
"session.summary.basedOn": "Based on {{branch}}",
"workspace.type.local": "local", "workspace.type.local": "local",
"workspace.type.sandbox": "sandbox", "workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create workspace", "workspace.create.failed.title": "Failed to create workspace",

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