Compare commits

..
24 Commits
Author SHA1 Message Date
opencode-agent[bot] eec001baa3 Fix beta integration 2026-08-13 21:25:12 +00:00
opencode-agent[bot] f7ade2ef03 Apply PR #40427: some experimental perf improvements 2026-08-13 21:24:10 +00:00
opencode-agent[bot] c578bb57ed Apply PR #38790: feat(app): add workspace flows to new layout 2026-08-13 21:21:19 +00:00
Dax Raad 40cd6989d2 fix(core): update console server URL 2026-08-13 17:04:46 -04:00
Kit Langton 7b89f06402 feat(tui): prototype missing location recovery (#42353) 2026-08-13 16:39:07 -04:00
Kit Langton 3a41ee8817 fix(tui): stop attention glow breathing (#42404) 2026-08-13 16:38:06 -04:00
Kit Langton b882ccc57d fix(core): preserve instruction state on move (#42398) 2026-08-13 16:20:14 -04:00
Kit Langton 4836356c17 fix(tui): stop disabled tab pulse rendering (#42346) 2026-08-13 15:56:36 -04:00
Kit Langton 90fd61225e feat(core): replace webfetch markdown renderer (#42229) 2026-08-13 15:53:53 -04:00
Kit Langton e5d79ae187 fix(tui): load transcript history on demand (#42388) 2026-08-13 19:52:39 +00:00
Kit Langton b66f01b37c feat(tui): prototype tab scroll memory (#42375) 2026-08-13 15:10:34 -04:00
Filip d2b8cb8081 fix: docs light theme (#42395) 2026-08-13 21:06:23 +02:00
opencode-agent[bot]andJames Long ed708f9dc2 fix(tui): keep shell commands full width (#42339)
Co-authored-by: James Long <17031+jlongster@users.noreply.github.com>
2026-08-13 14:45:09 -04:00
LukeParkerDev 7b5eb0bdcf test(app): remove obsolete workspace flows 2026-08-13 20:25:44 +10:00
LukeParkerDev 3806390f36 fix(app): stabilize workspace settings filters 2026-08-13 20:03:18 +10:00
LukeParkerDev 3cb372972b fix(app): avoid workspace filter stalls 2026-08-13 19:43:32 +10:00
LukeParkerDev e70b8358e6 Merge remote-tracking branch 'upstream/v2' into workspaces-readyish
# Conflicts:
#	packages/app/e2e/regression/session-request-docks.spec.ts
#	packages/app/src/components/settings-v2/dialog-settings-v2.tsx
#	packages/app/src/context/language.tsx
#	packages/app/src/context/server-sync.test.ts
#	packages/app/src/context/server-sync.tsx
#	packages/app/src/i18n/parity.test.ts
#	packages/app/src/pages/session.tsx
#	packages/app/src/pages/session/session-side-panel.tsx
#	packages/app/src/pages/session/timeline/message-timeline.tsx
#	packages/app/src/pages/session/timeline/rows.ts
2026-08-13 19:35:16 +10:00
LukeParkerDev fca453a2a5 feat(app): retarget workspace flows to v2 2026-08-11 17:06:01 +10:00
LukeParkerDev 2a51cf5f88 fix(app): preserve lazy session navigation 2026-08-11 16:08:32 +10:00
LukeParkerDev b7bd2b0ae0 fix(session-ui): parse composer input once 2026-08-11 16:08:32 +10:00
LukeParkerDev 45b2759a1e fix(session-ui): bound completed markdown rendering 2026-08-11 16:08:32 +10:00
LukeParkerDev 65b6abc4bb fix(app): avoid renderer Shiki WASM startup 2026-08-11 16:08:31 +10:00
LukeParkerDev 8c8492ef97 fix(app): yield before session history ingestion 2026-08-11 16:08:31 +10:00
LukeParkerDev 115e689723 fix(app): reduce session history page size 2026-08-11 16:08:31 +10:00
152 changed files with 6699 additions and 7321 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode-ai/core": patch
---
Preserve prompt cache prefixes when sessions move between locations with unchanged instructions.
+1 -1
View File
@@ -171,4 +171,4 @@ const table = sqliteTable("session", {
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement and committed revert clear it. Unavailable sources retain the last value and block only the initial complete delta.
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
-8
View File
@@ -393,7 +393,6 @@
"mime-types": "3.0.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
@@ -414,7 +413,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
},
@@ -1966,8 +1964,6 @@
"@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -3258,8 +3254,6 @@
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
"@types/turndown": ["@types/turndown@5.0.5", "", {}, "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
@@ -5774,8 +5768,6 @@
"turbo": ["turbo@2.10.2", "", { "optionalDependencies": { "@turbo/darwin-64": "2.10.2", "@turbo/darwin-arm64": "2.10.2", "@turbo/linux-64": "2.10.2", "@turbo/linux-arm64": "2.10.2", "@turbo/windows-64": "2.10.2", "@turbo/windows-arm64": "2.10.2" }, "bin": { "turbo": "bin/turbo" } }, "sha512-wTExrNrRjB8qzIcg+ZLm0A3GFNLDsWNwdS/RBXB0FPrBDyzk3i96Yx+TxWZC7a0k1SIreFB8ciUbxjmEqTH8IQ=="],
"turndown": ["turndown@7.2.0", "", { "dependencies": { "@mixmark-io/domino": "^2.2.0" } }, "sha512-eCZGBN4nNNqM9Owkv9HAtWRYfLA4h909E/WGAWWBpmB275ehNhZyk87/Tpvjbp0jjNl9XwCsbe6bm6CqFsgD+A=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"tw-to-css": ["tw-to-css@0.0.12", "", { "dependencies": { "postcss": "8.4.31", "postcss-css-variables": "0.18.0", "tailwindcss": "3.3.2" } }, "sha512-rQAsQvOtV1lBkyCw+iypMygNHrShYAItES5r8fMsrhhaj5qrV2LkZyXc8ccEH+u5bFjHjQ9iuxe90I7Kykf6pw=="],
+2
View File
@@ -22,6 +22,8 @@
## 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.
- 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.
- 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.
@@ -116,7 +116,7 @@ test("restores the draft caret before typing after a request dock closes", async
})
await mockServer(page, { forms: [] })
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
await transport.waitForConnection()
await transport.waitForConnection({ path: "/api/event" })
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
@@ -17,7 +17,7 @@ import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const initialPageSize = 20
const historyPageSize = 200
const historyPageSize = 50
const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
return [
@@ -89,13 +89,15 @@ test("reconnects after a stream error", async ({ page }) => {
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
const timeline = await setupTimeline(page, { eventRetry: 10 })
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
id: "timeline-event-7",
})
const first = await timeline.transport.send(
partUpdated(textPart("prt_transport_id", "event with id")),
{ id: "timeline-event-7" },
"/api/event",
)
await timeline.waitForPart("prt_transport_id")
await timeline.transport.error("retry with event id")
const connection = await timeline.transport.waitForConnection({ after: first.connectionID })
await timeline.transport.error("retry with event id", "/api/event")
const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" })
expect(first.eventID).toBe("timeline-event-7")
expect(connection.headers["last-event-id"]).toBeUndefined()
+25 -4
View File
@@ -270,6 +270,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
if (path === "/api/project") return json(route, [config.project])
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
if (/^\/api\/project\/[^/]+\/directories$/.test(path))
return json(route, [
{ directory: config.directory },
...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({
directory,
strategy: "git_worktree",
})),
])
if (path === "/api/location") return json(route, location(config))
const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1]
if (projectCopy && route.request().method() === "POST") {
@@ -343,7 +351,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
const limit = Number(url.searchParams.get("limit") ?? 50)
const offset = Number(url.searchParams.get("cursor") ?? 0)
const sessions = config.sessions
.filter((session) => !directory || session.directory === directory)
.filter((session) => {
const location = session.location as { directory?: string } | undefined
return !directory || location?.directory === directory || session.directory === directory
})
.filter((session) => parentID !== "null" || session.parentID === undefined)
.filter((session) => {
const search = url.searchParams.get("search")?.toLowerCase()
@@ -578,6 +589,7 @@ function currentPermission(value: unknown) {
export function currentSession(session: { id: string } & Record<string, unknown>, fallbackDirectory?: string) {
const time = session.time && typeof session.time === "object" ? session.time : {}
const location = session.location && typeof session.location === "object" ? session.location : {}
return {
id: session.id,
parentID: session.parentID,
@@ -595,10 +607,19 @@ export function currentSession(session: { id: string } & Record<string, unknown>
},
title: session.title ?? session.id,
location: {
directory: typeof session.directory === "string" ? session.directory : fallbackDirectory,
...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}),
directory:
"directory" in location && typeof location.directory === "string"
? location.directory
: typeof session.directory === "string"
? session.directory
: fallbackDirectory,
...(typeof session.workspaceID === "string"
? { workspaceID: session.workspaceID }
: "workspaceID" in location && typeof location.workspaceID === "string"
? { workspaceID: location.workspaceID }
: {}),
},
subpath: session.path,
subpath: session.subpath ?? session.path,
revert: session.revert,
}
}
+36 -17
View File
@@ -29,23 +29,38 @@ export type SseEventOptions = {
export type SseTransport<T> = {
server: string
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
waitForConnection(options?: {
after?: number
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[]>
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
close(): Promise<void>
disconnect(message?: string): Promise<void>
error(message?: string): Promise<void>
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
connections(): Promise<SseConnectionRecord[]>
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
}
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: "end"; mode: "close" | "disconnect" | "error"; message?: string }
| {
type: "end"
mode: "close" | "disconnect" | "error"
message?: string
path?: SseConnectionRecord["path"]
}
| { type: "connections" }
| { type: "acknowledgements" }
@@ -73,7 +88,8 @@ export async function installSseTransport<T>(
let nextConnectionID = 0
let nextDeliveryID = 0
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
const current = (path?: SseConnectionRecord["path"]) =>
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
const boundaries = [...new Set(cuts ?? [])]
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
@@ -125,8 +141,8 @@ export async function installSseTransport<T>(
acknowledgements.push(acknowledgement)
return acknowledgement
}
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
const connection = current()
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
const connection = current(path)
if (!connection) throw new Error("SSE transport has no active connection")
connection.endedAt = performance.now()
connection.endedBy = mode
@@ -146,8 +162,8 @@ export async function installSseTransport<T>(
if (input.type === "connections")
return connections.map(({ controller: _controller, ...connection }) => connection)
if (input.type === "acknowledgements") return acknowledgements
if (input.type === "end") return end(input.mode, input.message)
const connection = current()
if (input.type === "end") return end(input.mode, input.message, input.path)
const connection = current(input.type === "send" ? input.path : undefined)
if (!connection) throw new Error("SSE transport has no active connection")
if (input.type === "raw") {
marker(input.marker)
@@ -235,12 +251,15 @@ export async function installSseTransport<T>(
server,
async waitForConnection(input = {}) {
const connection = await page.waitForFunction(
(after) => {
({ after, path }) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
return connections?.findLast(
(connection) =>
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
)
},
input.after ?? 0,
{ after: input.after ?? 0, path: input.path },
{ timeout: input.timeout },
)
let result: SseConnectionRecord | undefined
@@ -252,8 +271,8 @@ export async function installSseTransport<T>(
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
},
send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
send(payload, eventOptions, path) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
},
burst(payloads, eventOptions = []) {
return command({
@@ -291,8 +310,8 @@ export async function installSseTransport<T>(
disconnect(message) {
return command({ type: "end", mode: "disconnect", message })
},
error(message) {
return command({ type: "end", mode: "error", message })
error(message, path) {
return command({ type: "end", mode: "error", message, path })
},
connections() {
return command({ type: "connections" })
+10 -33
View File
@@ -1,6 +1,7 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
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 { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
@@ -51,10 +52,9 @@ import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { legacySessionServer, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64"
import { SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session"
import { TargetSessionRoute } from "@/pages/session-lazy"
import { Home } from "@/pages/home"
const NewSession = lazy(() => import("@/pages/new-session"))
@@ -75,35 +75,6 @@ const DirectoryDraftRedirect = () => {
return null
}
function TargetServerRoute(props: ParentProps) {
const params = useParams<{ serverKey: string; id: string }>()
const global = useGlobal()
const conn = createMemo(() => {
const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn()}>
<ServerSyncProvider server={conn()}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
function TargetSessionRoute() {
const params = useParams<{ serverKey: string; id: string }>()
return (
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</SessionRouteErrorBoundary>
)
}
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) {
@@ -159,7 +130,13 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>
@@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
accentSubmit?: boolean
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}
@@ -1,18 +1,16 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
type SessionCreateInput = {
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
}
const optimistic: Array<{
directory?: string
sessionID?: string
@@ -22,11 +20,9 @@ const optimistic: Array<{
variant?: string
}
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = []
const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
@@ -37,15 +33,28 @@ const switchedModels: Array<{
model: { id: string; providerID: string; variant?: string }
}> = []
const sessionRequestOrder: string[] = []
const commands: Array<{ name: string }> = []
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
const syncedServers: string[] = []
const optimisticServers: string[] = []
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
let serverSessionSyncs = 0
let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
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> = {}
const sessionDirectories: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
@@ -73,21 +82,25 @@ const prompt = {
replaceComments: () => undefined,
items: () => [],
},
capture: () => prompt,
capture: (scope?: unknown, target?: unknown) => {
promptCaptures.push({ scope, target })
return prompt
},
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
const clientFor = (directory: string) => {
createdClients.push(directory)
return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
create: async (input: SessionCreateInput) => {
await createSessionGate
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
const id = `session-${createdSessions.length}`
sessionDirectories[id] = location
return {
id: `session-${createdSessions.length}`,
id,
projectID: "project",
agent: input.agent,
model: input.model,
@@ -100,7 +113,7 @@ const clientFor = (directory: string) => {
},
prompt: async (input: unknown) => {
sessionRequestOrder.push("prompt")
sentPrompts.push(directory)
sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory)
promptInputs.push(input)
return { data: undefined }
},
@@ -120,16 +133,23 @@ const clientFor = (directory: string) => {
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory)
},
},
projectCopy: {
create: async (_input: unknown) => {
worktreeCreates++
await createWorktreeGate
if (worktreeFailure) throw worktreeFailure
return { directory: worktreeDirectory }
},
},
location: { get: async () => ({ directory: worktreeDirectory }) },
},
session: {
command: async () => ({ data: undefined }),
abort: async () => ({ data: undefined }),
},
worktree: {
create: async () => ({ data: { directory: `${directory}/new` } }),
},
}
}
@@ -145,6 +165,7 @@ beforeAll(async () => {
mock.module("@opencode-ai/ui/toast", () => ({
Toast: { Region: () => null },
toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined },
showToast: () => 0,
}))
@@ -162,20 +183,13 @@ beforeAll(async () => {
current: () => ({ name: "agent" }),
},
session: {
promote(directory: string, sessionID: string) {
promoted.push({ directory, sessionID })
},
promote: () => undefined,
},
}),
}))
mock.module("@/context/permission", () => {
const state = (server: string) => ({
enableAutoAccept(sessionID: string, directory: string) {
enabledAutoAccept.push({ server, sessionID, directory })
},
})
return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) }
return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) }
})
mock.module("@/context/server", () => ({
@@ -184,7 +198,10 @@ beforeAll(async () => {
mock.module("@/context/tabs", () => ({
useTabs: () => ({
draft: () => ({ server: "project-server" }),
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
updateDraft: (draftID: string, draft: { worktree?: string }) => {
updatedDrafts.push({ draftID, ...draft })
},
promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => {
promotedDrafts.push({ draftID, ...session })
},
@@ -205,68 +222,70 @@ beforeAll(async () => {
mock.module("@/context/sdk", () => ({
useSDK: () => {
const sdk = {
scope: "local",
directory: "/repo/main",
return () => ({
scope: activeSDK === "server-a" ? ServerScope.local : "server-b",
directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other",
api: rootClient.api,
url: "http://localhost:4096",
}
return () => sdk
})
},
}))
mock.module("@/context/sync", () => ({
useSync: () => () => ({
data: { command: commands },
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => {
optimistic.push(value)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
useSync: () => () => {
const server = activeDirectorySync
return {
data: { command: commands, project: "project" },
session: {
optimistic: {
add: (value: {
directory?: string
sessionID?: string
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
}) => {
optimisticServers.push(server)
optimistic.push(value)
},
remove: () => undefined,
},
remove: () => undefined,
},
},
set: () => undefined,
}),
set: () => undefined,
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
}
},
}))
mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
},
child: (directory: string) => {
syncedDirectories.push(directory)
storedSessions[directory] ??= []
return [
{ session: storedSessions[directory] },
(...args: unknown[]) => {
if (args[0] !== "session") return
const next = args[1]
if (typeof next === "function") {
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
return
}
if (Array.isArray(next)) {
storedSessions[directory] = next as Array<{ id: string; title?: string }>
}
useServerSync: () => () => {
const server = activeServerSync
return {
session: {
remember: () => undefined,
set: () => undefined,
sync: async () => {
serverSessionSyncs++
},
]
},
}),
},
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", () => ({
@@ -286,205 +305,129 @@ beforeAll(async () => {
})
beforeEach(() => {
createdClients.length = 0
createdSessions.length = 0
sessionCreateInputs.length = 0
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
switchedAgents.length = 0
switchedModels.length = 0
sessionRequestOrder.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
syncedServers.length = 0
optimisticServers.length = 0
promptCaptures.length = 0
params = {}
search = {}
sentShell.length = 0
syncedDirectories.length = 0
sentShellDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
permissionServer = "server-a"
activeSDK = "server-a"
activeServerSync = "server-a"
activeDirectorySync = "server-a"
commands = []
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
worktreeDirectory = `/repo/new-${++worktreeID}`
createSessionGate = undefined
serverSessionSyncs = 0
createWorktreeGate = undefined
worktreeFailure = undefined
worktreeCreates = 0
for (const key of Object.keys(draftServers)) delete draftServers[key]
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[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", () => {
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = makeSubmit()
const event = { preventDefault: () => undefined } as unknown as Event
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
await submit.handleSubmit(event)
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
await settle()
expect(createdClients).toEqual([])
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
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"])
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
})
test("applies auto-accept to newly created sessions", async () => {
const submit = createPromptSubmit({
prompt,
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 event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("keeps auto-accept bound to the submission server", async () => {
test("keeps async submission effects bound to the initiating context", async () => {
search = { draftId: "draft-1" }
draftServers["draft-1"] = "project-server-a"
draftServers["draft-2"] = "project-server-b"
let release = () => {}
createSessionGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
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,
let submitted = 0
const submit = makeSubmit({
onSubmit: () => submitted++,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("promotes drafts using the selected project's server", async () => {
search = { draftId: "draft-1" }
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(submitted).toBe(0)
})
test("switches the selected agent and model before prompting", async () => {
params = { id: "session-1" }
variant = "high"
const submit = createPromptSubmit({
prompt,
const submit = makeSubmit({
info: () => ({
id: "session-1",
agent: "old-agent",
model: { id: "old-model", providerID: "old-provider" },
}),
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 Bun.sleep(0)
@@ -519,24 +462,12 @@ describe("prompt submit worktree selection", () => {
commands.push({ name: "review" })
promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }]
const submit = createPromptSubmit({
prompt,
const submit = makeSubmit({
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({ preventDefault: () => undefined } as unknown as Event)
await submit.handleSubmit(event)
await settle()
expect(sentCommands).toEqual([
{
@@ -552,66 +483,19 @@ describe("prompt submit worktree selection", () => {
expect(serverSessionSyncs).toBe(0)
})
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
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,
test("sends an initial shell after synchronous workspace creation", async () => {
selected = "create"
const submit = makeSubmit({
mode: () => "shell",
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(optimistic[0]).toMatchObject({
message: {
model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" },
},
})
})
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
await settle()
expect(storedSessions["/repo/worktree-a"]).toHaveLength(1)
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
expect(sentShellDirectories).toEqual([worktreeDirectory])
expect(sentShell[0]).toMatchObject({
sessionID: "session-1",
command: "ls",
})
})
})
+220 -289
View File
@@ -15,7 +15,6 @@ import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt
import { useSDK, type DirectorySDK } from "@/context/sdk"
import { useSync, type DirectorySync } from "@/context/sync"
import { Identifier } from "@/utils/id"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { getDirectory } from "@opencode-ai/core/util/path"
import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
@@ -25,12 +24,7 @@ import { createPromptSubmissionState } from "./submission-state"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
type PendingPrompt = {
abort: AbortController
cleanup: VoidFunction
}
const pending = new Map<string, PendingPrompt>()
const submitting = new Set<string>()
export type FollowupDraft = {
sessionID: string
@@ -50,7 +44,6 @@ type FollowupSendInput = {
draft: FollowupDraft
messageID?: string
optimisticBusy?: boolean
before?: () => Promise<boolean> | boolean
}
const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? part.content : "")).join("")
@@ -70,22 +63,11 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
}
const wait = async () => {
const ok = await input.before?.()
if (ok === false) return false
return true
}
const [head, ...tail] = text.split(" ")
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
setBusy()
try {
if (!(await wait())) {
setIdle()
return false
}
const messageID = Identifier.ascending("message")
await input.api.command({
sessionID: input.draft.sessionID,
@@ -159,14 +141,6 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
})
try {
if (!(await wait())) {
batch(() => {
setIdle()
remove()
})
return false
}
const session = input.session()
if (session?.agent !== input.draft.agent) {
await input.api.switchAgent({ sessionID: input.draft.sessionID, agent: input.draft.agent })
@@ -263,8 +237,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID)
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message
if (err && typeof err === "object" && "data" in err) {
@@ -278,19 +250,10 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const abort = async () => {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync().session.set("todo", sessionID, [])
input.onAbort?.()
const key = pendingKey(sessionID)
const queued = pending.get(key)
if (queued) {
queued.abort.abort()
queued.cleanup()
pending.delete(key)
return Promise.resolve()
}
return sdk()
.api.session.interrupt({ sessionID })
.catch(() => {})
@@ -319,9 +282,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
}
const seed = (dir: string, info: SessionInfo) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
target.session.remember(info)
const [, setStore] = target.child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -353,7 +316,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (input.working()) void abort()
return
}
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
@@ -366,283 +328,252 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const submissionSDK = sdk()
const submissionSync = sync()
const submissionServerSync = serverSync()
const submissionScope = submissionSDK.scope
const projectDirectory = submissionSDK.directory
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 isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
const submissionKey = ScopedKey.from(
submissionScope,
draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`,
)
if (submitting.has(submissionKey)) return
submitting.add(submissionKey)
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await sdk()
.api.worktree.create({
projectID: sync().data.project,
strategy: "git",
directory: getDirectory(projectDirectory),
try {
input.addToHistory(currentPrompt, mode)
input.resetHistoryNavigation()
let sessionDirectory = projectDirectory
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await submissionSDK.api.projectCopy
.create({
projectID: submissionSync.data.project,
strategy: "git_worktree",
directory: getDirectory(projectDirectory),
location: { directory: projectDirectory },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree) return
await submissionSDK.api.location.get({ location: { directory: createdWorktree.directory } })
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
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 },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.worktreeCreateFailed.title"),
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree) return
WorktreeState.pending(sdk().scope, createdWorktree.directory)
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
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 },
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
if (created) {
seed(submissionServerSync, sessionDirectory, created)
session = created
await startTransition(() => {
if (!session) return
if (draftID) tabs.updateDraft(draftID, { worktree: undefined })
if (!draftID) resetWorktree?.()
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
localSession.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
handoff.setTabs(base64Encode(sessionDirectory), session.id)
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(
capturePrompt(
{ dir: base64Encode(sessionDirectory), id: session.id },
{ server: draftServer, scope: submissionScope },
),
)
})
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
}
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const draft: FollowupDraft = {
sessionID: session.id,
sessionDirectory,
prompt: currentPrompt,
context,
agent,
model,
variant,
}
const clearInput = () => {
submission.clear()
input.setMode("normal")
input.setPopover(null)
}
const restoreInput = () => {
const restored = submission.restore()
if (!restored) return false
restored.target.set(restored.prompt, input.promptLength(restored.prompt))
if (!submission.current(prompt.capture())) return true
input.setMode(mode)
input.setPopover(null)
requestAnimationFrame(() => {
const editor = input.editor()
if (!editor) return
editor.focus()
setCursorPosition(editor, input.promptLength(currentPrompt))
input.queueScroll()
})
return true
}
if (!isNewSession && mode === "normal" && input.shouldQueue?.()) {
input.onQueue?.(draft)
clearContext(submission.target())
clearInput()
return
}
input.onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
sdk()
.api.session.shell({
sessionID: session.id,
id: eventID,
command: text,
if (!session) {
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
description: language.t("prompt.toast.promptSendFailed.description"),
})
.catch((err) => {
showToast({
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
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) {
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()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
return
}
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
void submissionSDK.api.session
.shell({
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,
})),
),
id: eventID,
command: text,
})
.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")),
title: language.t("prompt.toast.shellSendFailed.title"),
description: errorMessage(err),
})
restoreInput()
})
return
}
}
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
sync().session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
const waitForWorktree = async () => {
const worktree = WorktreeState.get(sdk().scope, sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync().set("session_status", session.id, { type: "idle" })
}
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
}
pending.set(pendingKey(session.id), { abort: controller, cleanup })
const abortWait = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
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")
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
void submissionSDK.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) => {
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
}
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(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(),
session: () => input.info() ?? session,
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),
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
const messageID = Identifier.ascending("message")
const removeOptimisticMessage = () => {
submissionSync.session.optimistic.remove({
directory: sessionDirectory,
sessionID: session.id,
messageID,
})
}
for (const item of commentItems) submission.target().context.remove(item.key)
clearInput()
void sendFollowupDraft({
api: submissionSDK.api.session,
sync: submissionSync,
serverSync: submissionServerSync,
session: () => session,
draft,
messageID,
optimisticBusy: sessionDirectory === projectDirectory,
}).catch((err) => {
if (sessionDirectory === projectDirectory) {
submissionSync.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)
})
removeOptimisticMessage()
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
})
} finally {
submitting.delete(submissionKey)
}
}
return {
@@ -1,8 +1,7 @@
import { For, Show } from "solid-js"
import { createMemo, createSignal, For, Show } from "solid-js"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getFilename } from "@opencode-ai/core/util/path"
import { useLanguage } from "@/context/language"
@@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: {
projectRoot: string
workspaces: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
let pending: string | undefined
const [search, setSearch] = createSignal("")
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 workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "workspace-new"
return "workspace"
return "workspace-isolated"
}
const select = (value: string) => {
pending = value
pending = { type: "select", value }
}
const onOpenChange = (open: boolean) => {
if (open) return
const value = pending
if (open) {
setSearch("")
return
}
const action = pending
pending = undefined
if (value) props.onChange(value)
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "viewAll") {
props.onViewAll()
return
}
props.onDone()
}
const label = () => {
@@ -41,87 +57,214 @@ export function PromptWorkspaceSelector(props: {
return (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
<TooltipV2
placement="top"
openDelay={800}
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-start">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
</div>
) : (
language.t("session.new.workspace.trigger.tooltip")
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<PromptGitStatus branch={props.branch} />
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
placement="right"
openDelay={800}
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"
openDelay={800}
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 ps-3 pe-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="ms-1" />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch
}
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
<TooltipV2
placement="top"
value={value()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<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">
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
)}
</Show>
)
@@ -0,0 +1,141 @@
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createStore } from "solid-js/store"
import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js"
import type { Project } from "@/types"
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 { showToast } from "@/utils/toast"
import { workspaceDirectories } from "@/utils/workspace"
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 [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
const blocked = () => props.eligible === false || serverSync().session.data.session_working(props.sessionID)
const workspaces = () => directories().filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
const onOpenChange = (open: boolean) => {
props.onOpenChange?.(open)
if (!open) return
const sdk = serverSDK()
void sdk.api.projectCopy
.refresh({ projectID: props.project.id, location: { directory: props.directory } })
.then(() =>
sdk.api.project.directories({ projectID: props.project.id, location: { directory: props.directory } }),
)
.then((items) =>
setDirectories(items.filter((item) => item.strategy !== undefined).map((item) => item.directory)),
)
.catch(() => undefined)
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK()
const sessionID = props.sessionID
const source = props.directory
setStore("selected", selection)
try {
const destination = selection === "create" ? await createWorkspace(props.project, source, sdk) : selection
if (!destination) return
await sdk.api.session.move({ sessionID, directory: destination })
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.move.failed"),
description: 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={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(
project: Project,
source: string,
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
) {
const created = await serverSDK.api.projectCopy.create({
projectID: project.id,
strategy: "git_worktree",
directory: getDirectory(source),
location: { directory: source },
})
await serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
}
@@ -11,6 +11,7 @@ import { SettingsNotificationsV2 } from "./notifications"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
import { SettingsServersV2 } from "./servers"
import { SettingsWorkspacesV2 } from "./workspaces"
import { SettingsProjectsV2 } from "./projects"
import { SettingsExtensionsV2 } from "./extensions"
import { SettingsServerScope } from "../settings-server-picker"
@@ -95,6 +96,10 @@ export const DialogSettings: Component<{
<Icon name="folder" />
{language.t("settings.tab.projects")}
</TabsV2.Trigger>
<TabsV2.Trigger value="workspaces">
<Icon name="workspace-isolated" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
</div>
{/* Group 3: Capabilities & Extensions */}
@@ -140,6 +145,9 @@ export const DialogSettings: Component<{
<SettingsProjectsV2 />
</TabsV2.Content>
<SettingsServerScope directory={directory()}>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory()} onBack={showProviders} />
</TabsV2.Content>
@@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useUpdaterAction } from "../updater-action"
import { useSettings } from "@/context/settings"
import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings"
import { ExternalLink } from "../external-link"
import { SettingsListV2 } from "./parts/list"
import { SettingsRowV2 } from "./parts/row"
@@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController
)
}
const WorkspaceDestinationSetting: Component = () => {
const language = useLanguage()
const settings = useSettings()
const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [
{ value: "last-used", label: language.t("settings.workspaces.default.lastUsed") },
{ value: "local", label: language.t("settings.workspaces.default.local") },
{ value: "new", label: language.t("settings.workspaces.default.new") },
])
return (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
@@ -300,6 +328,7 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2>
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} />
@@ -362,18 +391,6 @@ export const SettingsGeneralV2: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<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
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}
@@ -692,6 +692,223 @@
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"] {
width: 480px;
max-width: calc(100vw - 32px);
@@ -0,0 +1,492 @@
import type { Component } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import type { SessionInfo } from "@opencode-ai/client/promise"
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 { 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 { InlineServerSelect } from "./parts/server-select"
import { useTabs } from "@/context/tabs"
import { usePlatform } from "@/context/platform"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { ServerConnection } from "@/context/server"
import type { Project } from "@/types"
import {
containsDirectory,
filterWorkspaceInventory,
inspectWorkspaceDeletion,
mergeWorkspaceSessionInventory,
removeWorkspacesSequentially,
sessionsForWorkspace,
type WorkspaceDeleteInspection,
workspaceInventory,
} from "@/utils/workspace"
import { listAllSessions } 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 fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
return mergeWorkspaceSessionInventory(
fetched,
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
)
}
const sessionQuery = useQuery(() => ({
queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const,
queryFn: () => loadSessions(),
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(
() =>
new Map(
workspaces().map((workspace) => [
pathKey(workspace.directory),
sessionQuery.isSuccess ? sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) : [],
]),
),
)
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(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: SessionInfo) => {
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.api.projectCopy
.remove({
projectID: workspace.project.id,
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force: allowDirty,
})
.then(() => true)
.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">
<div class="settings-v2-tab-header-row">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</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}>
<MenuV2 placement="bottom-end" gutter={6}>
<MenuV2.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium 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">
<span class="min-w-0 truncate">
{projectOptions().find((option) => option.id === selectedProject())?.label}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<For each={projectOptions()}>
{(option) => (
<MenuV2.Item onSelect={() => setStore("project", option.id)}>
<span class="min-w-0 flex-1 truncate">{option.label}</span>
<Show when={selectedProject() === option.id}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</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: SessionInfo[] }>
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>
)
}
@@ -106,16 +106,26 @@ describe("query keys", () => {
})
test("loads projects from the current endpoint", async () => {
const calls: string[] = []
const api = {
list: async () => [
{ id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
directories: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/copy`, strategy: "git_worktree" },
]
},
} as unknown as ProjectApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([["/a/copy"], ["/b/copy"]])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("loads references from the current location-scoped endpoint", async () => {
@@ -105,6 +105,7 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
readonly directories: ServerApi["project"]["directories"]
}
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
@@ -118,10 +119,16 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) =>
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
api.list().then((projects) => {
return projects
.filter((p) => !!p?.id)
.map(normalizeProjectInfo)
api.list().then(async (projects) => {
return (await Promise.all(
projects.filter((project) => !!project?.id).map(async (project) => {
const directories = await api.directories({ projectID: project.id })
return normalizeProjectInfo({
...project,
sandboxes: directories.filter((item) => item.strategy !== undefined).map((item) => item.directory),
})
}),
))
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type {
@@ -187,10 +187,10 @@ export function applyDirectoryEvent(input: {
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
}
case "worktree.resolved": {
case "project.directory.resolved": {
const properties = event.properties as { projectID: string; directory: string; previous: string }
input.store.session.forEach((session, index) => {
const adopted = Worktree.adopt(
const adopted = ProjectDirectories.adopt(
{ projectID: session.projectID, directory: session.location.directory },
properties,
)
+30 -13
View File
@@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import {
pluralCategory,
type UiI18nPluralLookupKey,
type UiI18nPluralKey,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
@@ -28,13 +33,17 @@ function localeDirection(locale: Locale): Direction {
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
| "session.background.shell"
| "session.background.subagent"
type AppI18nKey = Extract<keyof typeof en, string>
type AppI18nPluralKey = {
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
}[AppI18nKey]
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
type TranslationKey<Key extends Extract<keyof Dictionary, string>> = Key extends
| AppI18nPluralLookupKey
| UiI18nPluralLookupKey
? never
: Key
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
@@ -191,18 +200,25 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <
Key extends Extract<keyof Dictionary, string>,
>(
key: TranslationKey<Key>,
params?: Record<string, string | number | boolean>,
) => string
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}`
const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
}
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]
@@ -233,6 +249,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label,
t,
plural,
pluralForm,
setLocale(next: Locale) {
setStore("locale", normalizeLocale(next))
},
+12 -7
View File
@@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import type { ServerScope } from "@/utils/server-scope"
import {
createPromptReady,
createPromptSession,
@@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
const current = settings.general.newLayoutDesigns()
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
: undefined
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
const key = scopeKey(scope)
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK().scope, scope),
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
dispose,
}),
owner,
@@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
scope ? load(scope, target) : session()
const ready = createPromptReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) =>
@@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return {
ready,
capture: (scope?: PromptScope) => pick(scope).capture(),
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
pick(scope, target).capture(),
current: withSuspense(() => session().current()),
cursor: withSuspense(() => session().cursor()),
dirty: withSuspense(() => session().dirty()),
+1 -1
View File
@@ -72,7 +72,7 @@ describe("current event buffering", () => {
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
} satisfies Extract<OpenCodeEvent, { type: "session.tool.input.delta" }>)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
+3 -1
View File
@@ -154,7 +154,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
last = Date.now()
const output = coalesceServerEvents(events)
batch(() => {
output.forEach((event) => emitter.emit(event.directory, event.payload))
output.forEach((event) => {
emitter.emit(event.directory, event.payload)
})
})
buffer.length = 0
@@ -541,6 +541,23 @@ describe("server session", () => {
expect(ctx.store.lineage.peek("child")).toEqual(result)
})
test("applies moved session locations without evicting cached state", () => {
const current = { ...session("child"), location: { directory: "/repo/worktree" } }
const ctx = setup({ child: current })
ctx.store.remember(current)
ctx.store.applyV2({
id: "evt_moved",
created: 2,
type: "session.moved",
durable: { aggregateID: "child", seq: 1, version: 1 },
location: current.location,
data: { sessionID: "child", location: { directory: "/repo" }, projectID: "project", subpath: "packages/app" },
} satisfies Extract<OpenCodeEvent, { type: "session.moved" }>)
expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" })
})
test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") })
+27 -8
View File
@@ -1,5 +1,5 @@
import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { retry } from "@opencode-ai/core/util/retry"
import type {
FormInfo,
@@ -24,7 +24,7 @@ type MessageApi = ServerApi["message"]
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
const initialMessagePageSize = 20
const historyMessagePageSize = 200
const historyMessagePageSize = 50
const sessionInfoLimit = 2_048
const emptyIDs: ReadonlySet<string> = new Set()
@@ -53,6 +53,12 @@ function projectMessageSource(message: Message): SessionMessageInfo[] {
]
}
function yieldToMain() {
const scheduler = (globalThis as { scheduler?: { yield: () => Promise<void> } }).scheduler
if (scheduler) return scheduler.yield()
return new Promise<void>((resolve) => setTimeout(resolve, 0))
}
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
const boundary = source.find(
(message) =>
@@ -257,7 +263,13 @@ export function createServerSession(
const indexProjectedMessage = (message: Message) => {
const current = data.session_message[message.sessionID] ?? []
if (current.some((item) => item.id === message.id)) return
setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)]))
const projected = projectMessageSource(message)
const projectedIDs = new Set(projected.map((item) => item.id))
setData(
"session_message",
message.sessionID,
reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]),
)
}
const remember = (session: SessionInfo) => {
@@ -307,17 +319,19 @@ export function createServerSession(
return session
}
const resolve = (sessionID: string, options?: { force?: boolean }) => {
const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => {
const cached = data.info[sessionID]
if (cached && !options?.force) return Promise.resolve(cached)
const pending = requests.get(sessionID)
const pending = options?.signal ? undefined : requests.get(sessionID)
if (pending) return pending
const active = generation(sessionID)
const request = sessionApi.get({ sessionID })
const request = sessionApi.get({ sessionID }, { signal: options?.signal })
const resolved = request.then((result) => {
if (options?.signal?.aborted) return result
if (generations.get(sessionID) !== active) return result
return remember(result)
})
if (options?.signal) return resolved
requests.set(sessionID, resolved)
const cleanup = () => {
if (requests.get(sessionID) === resolved) requests.delete(sessionID)
@@ -553,6 +567,7 @@ export function createServerSession(
if (!response.data.length) break
}
const response = pages.at(-1)!
await yieldToMain()
const source = pages.flatMap((page) => page.data).toReversed()
const normalized = normalizeSessionMessages(sessionID, source)
return {
@@ -940,10 +955,10 @@ export function createServerSession(
setData("form", event.data.sessionID, (forms) => forms?.filter((form) => form.id !== event.data.id))
return
}
if (event.type === "worktree.resolved") {
if (event.type === "project.directory.resolved") {
Object.values(data.info).forEach((info) => {
if (!info) return
const adopted = Worktree.adopt(
const adopted = ProjectDirectories.adopt(
{ projectID: info.projectID, directory: info.location.directory },
event.data,
)
@@ -1446,6 +1461,7 @@ export function createServerSession(
if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] })
if (!items)
optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]]))
indexProjectedMessage(input.message)
setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages))
setData(
"part_text_accum_delta",
@@ -1479,6 +1495,9 @@ export function createServerSession(
)
return
}
setData("session_message", input.sessionID, (messages) =>
messages?.filter((message) => message.id !== input.messageID),
)
setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID))
setData(produce((draft) => deleteMessageParts(draft, input.messageID)))
},
@@ -188,6 +188,18 @@ function sessionInfo(id: string) {
} as SessionInfo
}
function sessionAt(directory: string): SessionInfo {
return {
id: "session",
projectID: "project",
location: { directory },
title: "Session",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
}
}
describe("estimateRootSessionTotal", () => {
test("keeps exact total for full fetches", () => {
expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+24 -12
View File
@@ -58,6 +58,17 @@ import { createCatalogSync } from "./server-sync/catalog"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "./platform"
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
const type = event.current?.type ?? event.type
return (
type === "session.created" ||
type === "session.deleted" ||
type === "session.moved" ||
type === "session.renamed" ||
type === "session.forked"
)
}
type GlobalStore = {
ready: boolean
error?: InitError
@@ -553,12 +564,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
}
const toDirectoryEvent = (event: ServerEvent) => {
if (event.current?.type === "session.created") return
if (
event.current?.type !== "session.renamed" &&
event.current?.type !== "session.moved" &&
event.current?.type !== "session.usage.updated"
)
return event
if (event.current?.type !== "session.renamed" && event.current?.type !== "session.usage.updated") return event
const info = session.get(event.current.data.sessionID)
if (info) return { type: "session.updated", properties: { info } }
return event
@@ -576,6 +582,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
markSessionListChanged(event, directory, previousDirectory)
if (event.current) session.applyV2(event.current)
session.apply(event)
if (event.current?.type === "session.moved") {
const info = session.get(event.current.data.sessionID)
if (info) indexSession(info)
}
if (shouldRefreshWorkspaceSessions(event)) {
void queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions",
})
}
if (event.current?.type === "session.created")
void session
.resolve(event.current.data.sessionID, { force: true })
@@ -627,17 +643,13 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
if (
eventType === "config.updated" ||
eventType === "agent.updated" ||
eventType === "worktree.updated"
eventType === "project.directories.updated"
)
bootstrap.refetch()
if (eventType === "global.disposed") Object.keys(children.children).filter(children.active).forEach(queue.push)
return
}
if (event.current?.type === "session.moved") {
const info = session.get(event.current.data.sessionID)
if (info) indexSession(info)
}
if (event.current?.type === "session.forked")
void session
.resolve(event.current.data.sessionID, { force: true })
@@ -667,7 +679,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
void loadCommands(directory, serverSDK.api.command)
.then((commands) => setStore("command", commands))
.catch(() => {})
if (eventType === "worktree.updated") void bootstrap.refetch()
if (eventType === "project.directories.updated") void bootstrap.refetch()
const projected = toDirectoryEvent(event)
if (projected)
applyDirectoryEvent({
+35
View File
@@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type WorkspaceDefaultDestination = "last-used" | "local" | "new"
export type WorkspaceLastUsed = "local" | "workspace"
export interface NotificationSettings {
agent: boolean
@@ -44,6 +48,10 @@ export interface Settings {
permissions: {
autoApprove: boolean
}
workspaces: {
defaultDestination: WorkspaceDefaultDestination
lastUsed: Record<string, WorkspaceLastUsed>
}
notifications: NotificationSettings
sounds: SoundSettings
}
@@ -126,6 +134,10 @@ const defaultSettings: Settings = {
permissions: {
autoApprove: false,
},
workspaces: {
defaultDestination: "last-used",
lastUsed: {},
},
notifications: {
agent: true,
permissions: true,
@@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("permissions", "autoApprove", value)
},
},
workspaces: {
defaultDestination: withFallback(
() => store.workspaces?.defaultDestination,
defaultSettings.workspaces.defaultDestination,
),
setDefaultDestination(value: WorkspaceDefaultDestination) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
defaultDestination: value,
}))
},
lastUsed(scope: ServerScope, projectID: string) {
return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)]
},
setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) {
setStore("workspaces", (current) => ({
...defaultSettings.workspaces,
...current,
lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value },
}))
},
},
notifications: {
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) {
+5
View File
@@ -177,6 +177,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}
const actions = {
active() {
if (location.pathname === "/") return
const key = recentKey()
return store.find((tab) => tabKey(tab) === key)
},
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))
+40
View File
@@ -1166,6 +1166,46 @@ export const dict = {
"session.delete.button": "Delete session",
"workspace.new": "New workspace",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search workspaces",
"settings.tab.workspaces": "Workspaces",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No workspaces",
"settings.workspaces.count.one": "{{count}} workspace",
"settings.workspaces.count.other": "{{count}} workspaces",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all workspaces",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?",
"settings.workspaces.delete.warning":
"The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.",
"settings.workspaces.deleteAll.warning":
"The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.",
"settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
"settings.workspaces.default.local": "Local directory",
"settings.workspaces.default.new": "New workspace",
"workspace.move.title": "Move to workspace",
"workspace.move.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.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create workspace",
+5
View File
@@ -327,4 +327,9 @@
animation-range: 0 0.1px;
}
}
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
height: 24px;
padding-block: 0;
}
}
+1
View File
@@ -28,3 +28,4 @@ export {
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { createDraftStore, type DraftStore } from "./utils/draft-store"
export { preloadSessionRoute } from "./pages/session-lazy"
+16
View File
@@ -1,4 +1,5 @@
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { onCleanup, onMount } from "solid-js"
import { createHomeController } from "./home/home-controller"
import { createHomeProjectsController } from "./home/home-projects-controller"
import { HomeUtilityNav } from "./home/home-projects-view"
@@ -7,8 +8,23 @@ import { createHomeScrollController } from "./home/home-scroll-controller"
import { createHomeSessionSearchController } from "./home/home-session-search-controller"
import { createHomeSessionsController } from "./home/home-sessions-controller"
import { HomeSessions } from "./home/home-sessions"
import { preloadSessionRoute } from "./session-lazy"
export function Home() {
onMount(() => {
let idle: number | undefined
const timer = setTimeout(() => {
if ("requestIdleCallback" in window) {
idle = requestIdleCallback(() => void preloadSessionRoute(), { timeout: 3_000 })
return
}
void preloadSessionRoute()
}, 1_500)
onCleanup(() => {
clearTimeout(timer)
if (idle !== undefined) cancelIdleCallback(idle)
})
})
const home = createHomeController()
const projects = createHomeProjectsController(home)
const sessions = createHomeSessionsController(home)
+18 -2
View File
@@ -1,7 +1,10 @@
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useSettings } from "@/context/settings"
import { createEffect, createResource } from "solid-js"
import { useTabs, type DraftTab } from "@/context/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
@@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
export default function NewSessionPage() {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const workspace = createNewSessionWorkspaceController()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
onViewAll: openWorkspaces,
})
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
onSubmit: workspace.selection.remember,
})
const project = createPromptProjectController({
controls: draft.project.controls,
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
export function createNewSessionDraftController(workspace: {
worktree: () => string
resetWorktree: () => void
onSubmit: () => void
}) {
const prompt = usePrompt()
const serverSync = useServerSync()
const comments = useComments()
@@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
return workspace.worktree()
},
onNewSessionWorktreeReset: workspace.resetWorktree,
onSubmit: comments.clear,
onSubmit: () => {
workspace.onSubmit()
comments.clear()
},
})
createEffect(() => {
@@ -1,6 +1,6 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2"
import { Show, createMemo, createSignal } from "solid-js"
@@ -31,6 +31,15 @@ export function NewSessionView(props: {
project: PromptProjectController
workspace: NewSessionWorkspaceController
}) {
const [onboarding, setOnboarding, , onboardingReady] = persisted(
Persist.global("workspace-onboarding"),
createStore({ used: false }),
)
const select = (value: string) => {
props.workspace.selection.set(value)
if (value !== "main") setOnboarding("used", true)
}
return (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
@@ -41,7 +50,7 @@ export function NewSessionView(props: {
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<PromptInputV2Composer controller={props.input} />
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
@@ -59,8 +68,10 @@ export function NewSessionView(props: {
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onDone={props.input.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
</Show>
</div>
@@ -137,7 +148,7 @@ function ProviderTip() {
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<TooltipV2
@@ -152,7 +163,7 @@ function ProviderTip() {
aria-label={language.t("common.dismiss")}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<IconV2 name="xmark-small" />
<Icon name="xmark-small" />
</button>
</TooltipV2>
</div>
@@ -1,20 +1,28 @@
import { createMemo, createSignal } from "solid-js"
import { createMemo } from "solid-js"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
import { pathKey } from "@/utils/path-key"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
workspaceDefaultSelection,
workspaceDirectories,
} from "@/utils/workspace"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
return "main"
return input.fallback ?? "main"
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
@@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
return input.worktreeBranch(input.worktree) ?? input.local
}
export function createNewSessionWorkspaceController() {
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const [worktree, setWorktree] = createSignal<string>()
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const settings = useSettings()
const visible = createMemo(() => sync().project?.vcs === "git")
const selected = createMemo(() => {
const project = sync().project
const worktree = input.selected()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = sync().project
if (!project) return "main"
return workspaceDefaultSelection(
settings.workspaces.defaultDestination(),
settings.workspaces.lastUsed(serverSDK().scope, project.id),
)
})
const value = createMemo(() =>
resolveNewSessionWorktree({
enabled: visible(),
selected: worktree(),
selected: selected(),
directory: sdk().directory,
projectWorktree: sync().project?.worktree,
fallback: fallback(),
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
@@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
}),
)
const remember = (worktree = value()) => {
const project = sync().project
if (!project) return
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
}
return {
selection: {
value,
reset: () => setWorktree(),
set: (worktree: string) =>
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
workspace: createMemo(() => {
const project = sync().project
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => input.setSelected(undefined),
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
remember(worktree)
},
},
project: {
root: projectRoot,
workspaces: () => sync().project?.sandboxes ?? [],
workspaces: () => {
const project = sync().project
return project ? workspaceDirectories(project) : []
},
git: () => sync().project?.vcs === "git",
openAll: input.onViewAll,
},
bar: {
visible,
+4
View File
@@ -0,0 +1,4 @@
import { lazy } from "solid-js"
export const TargetSessionRoute = lazy(() => import("./target-session-route"))
export const preloadSessionRoute = TargetSessionRoute.preload
+36 -3
View File
@@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { isWorkspaceDirectory } from "@/utils/workspace"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
@@ -518,6 +519,8 @@ export default function Page() {
if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open()
}
const sessionDirectory = createMemo(() => controller.data.info()?.location.directory ?? sdk().directory)
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
const timeline = createTimelineModel({ session: controller })
const historyLoading = timeline.history.loading
const historyMore = timeline.history.more
@@ -572,6 +575,7 @@ export default function Page() {
const [store, setStore] = createStore({
...sessionViewState(),
newSessionWorktree: "main",
sessionDetailsOpen: false,
deferRender: false,
})
@@ -676,8 +680,23 @@ export default function Page() {
: skipToken,
}
})
const sessionDetailsQuery = createQuery(() => ({
queryKey: [serverSDK().scope, "session-details", sessionDirectory()] as const,
enabled:
store.sessionDetailsOpen && serverSDK().connection.status() === "connected" && sync().project?.vcs === "git",
queryFn: () =>
sdk()
.api.vcs.diff({ location: { directory: sessionDirectory() }, mode: "working" })
.then((result) => result.data)
.catch((error) => {
console.debug("[session-review] failed to load session details diff", { error })
return []
}),
}))
const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : [])
const refreshVcs = debounce(() => {
void queryClient.invalidateQueries({ queryKey: vcsKey() })
void queryClient.invalidateQueries({ queryKey: [serverSDK().scope, "session-details", sessionDirectory()] })
}, 100)
onCleanup(
sdk().event.listen((event) => {
@@ -1687,7 +1706,6 @@ export default function Page() {
}
const busy = (sessionID: string) => sync().data.session_working(sessionID)
const queuedFollowups = createMemo(() => {
const id = controller.identity.params.id
if (!id) return emptyFollowups
@@ -1700,6 +1718,12 @@ export default function Page() {
return followup.edit[id]
})
const workspaceMoveEligible = createMemo(() => {
const id = controller.identity.params.id
if (!id) return false
return (followup.items[id]?.length ?? 0) === 0 && !followup.failed[id] && !followup.paused[id] && !followup.edit[id]
})
const followupMutation = useMutation(() => ({
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
const owner = controller.ownership.capture()
@@ -2036,7 +2060,7 @@ export default function Page() {
>
{hasReview()
? language.t("session.review.filesChanged", { count: reviewCount() })
: language.t("session.review.change.other")}
: language.plural("session.review.change", 0)}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@@ -2102,6 +2126,9 @@ export default function Page() {
if (root) scheduleScrollState(root)
}}
userMessages={visibleUserMessages()}
diffs={sessionDetailsDiffs}
workspaceMoveEligible={workspaceMoveEligible()}
onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)}
setHistoryAnchor={(handlers) => {
captureHistoryAnchor = handlers.capture
restoreHistoryAnchor = handlers.restore
@@ -2229,7 +2256,13 @@ export default function Page() {
setFollowup("paused", id, true)
},
})
return <PromptInputV2Composer controller={promptInputController} borderUnderlay />
return (
<PromptInputV2Composer
controller={promptInputController}
borderUnderlay
accentSubmit={workspaceSession()}
/>
)
}}
</Show>
}
@@ -86,7 +86,7 @@ export function createPromptProjectControls() {
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined })
return
}
@@ -782,10 +782,7 @@ export function SessionSidePanel(props: {
when={settings.general.newLayoutDesigns()}
fallback={
<>
{props.reviewCount}{" "}
{language.t(
props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
)}
{props.reviewCount} {language.plural("session.review.change", props.reviewCount)}
</>
}
>
@@ -33,6 +33,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { SessionRetry } from "@opencode-ai/session-ui/session-retry"
@@ -41,7 +43,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { TextField } from "@opencode-ai/ui/text-field"
import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import type { AssistantMessage, ToolPart, UserMessage } from "@/types"
import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { normalize } from "@opencode-ai/session-ui/session-diff"
@@ -49,11 +51,19 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useLanguage } from "@/context/language"
import { useServerSync } from "@/context/server-sync"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useCommand } from "@/context/command"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
import { filterVirtualIndexes } from "./virtual-items"
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
import { isWorkspaceDirectory } from "@/utils/workspace"
import { SessionWorkspaceMenu } from "@/components/session-workspace-menu"
import { getProjectAvatarVariant } from "@/context/layout"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
const emptyTools: ToolPart[] = []
@@ -109,7 +119,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu
)
}
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) {
const language = useLanguage()
const maxFiles = 10
const [state, setState] = createStore({
@@ -137,6 +147,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
{showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")}
</span>
</Show>
{props.action}
</div>
<div data-component="session-turn-diffs-content">
<Accordion
@@ -191,6 +202,162 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
sessionID: string
project: Project
directory: string
messageID?: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ms-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
invisible: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement={inline() ? "bottom-end" : language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={inline() ? 4 : -22}
contentClass={inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pe-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
inline()
? "end-0 top-1/2"
: "hover-reveal end-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
aria-label={language.t("common.dismiss")}
onClick={(event) => {
event.stopPropagation()
props.onDismiss()
}}
>
<IconV2 name="xmark-small" />
</button>
</div>
)
}
function SessionSummaryPanel(props: {
project: Project
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
messageID?: string
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
}) {
const language = useLanguage()
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
const branch = () => props.branch ?? props.baseBranch
const row =
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
return (
<div data-component="session-summary-panel" class="w-[280px]">
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
</div>
<SessionWorkspaceMenu
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement={language.direction() === "rtl" ? "right-start" : "left-start"}
gutter={-22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-start">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span>{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span class="min-w-0 truncate">{branch()}</span>
</Show>
</div>
<button
type="button"
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChanges changes={props.diffs} />
</Show>
</button>
</div>
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
function TimelineDiffView(props: { diff: SummaryDiff }) {
const fileComponent = useFileComponent()
const view = normalize(props.diff)
@@ -219,6 +386,9 @@ type MessageTimelineProps = {
centered: boolean
setContentRef: (el: HTMLDivElement) => void
userMessages: UserMessage[]
diffs: Accessor<{ additions: number; deletions: number }[]>
workspaceMoveEligible: boolean
onSummaryOpenChange: (open: boolean) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
@@ -241,6 +411,10 @@ function MessageTimelineView(
) {
let touchGesture: number | undefined
const language = useLanguage()
const serverSync = useServerSync()
const sdk = useSDK()
const sync = useSync()
const command = useCommand()
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
const ownerSessionKey = props.data.sessionKey()
@@ -257,19 +431,35 @@ function MessageTimelineView(
const parentID = props.data.parentID
const parentTitle = props.data.parentTitle
const childTitle = props.data.childTitle
const showHeader = props.data.showHeader
const getMsgParts = props.data.parts
const getMsgPart = props.data.part
const projection = props.data.projection
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
const [summaryOpen, setSummaryOpen] = createSignal(false)
const setSummary = (open: boolean) => {
setSummaryOpen(open)
props.onSummaryOpenChange(open)
}
const sessionDiffs = createMemo(props.diffs)
createEffect(
on(sessionID, () => {
setSummary(false)
setWorkspaceSuggestionDismissed(false)
}),
)
const turnPadding = () => "px-4 md:px-5"
const showHeader = createMemo(() => props.data.showHeader() || workspaceSession())
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
const lastAssistantGroupKey = projection.lastAssistantGroupKey
const messageByID = projection.messageByID
const sessionMessageByID = projection.sessionMessageByID
const messageLastRowIndex = projection.messageLastRowIndex
const messageRowIndex = projection.messageRowIndex
const timelineRowByKey = projection.rowByKey
const timelineRows = projection.rows
const sessionMessageByID = projection.sessionMessageByID
const noticeContent = (message: SessionMessageInfo) => {
if (message.type === "agent-switched")
return {
@@ -788,7 +978,7 @@ function MessageTimelineView(
)
return (
<TimelineRowFrame row={commentStripRow()}>
<div class="w-full px-4 md:px-5 pb-2">
<div class={`w-full pb-2 ${turnPadding()}`}>
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
@@ -839,7 +1029,7 @@ function MessageTimelineView(
<TimelineRowFrame row={userMessageRow()}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-content" aria-live="off">
<Message
message={message()}
@@ -865,7 +1055,7 @@ function MessageTimelineView(
<TimelineRowFrame row={noticeRow()}>
<Show when={content()}>
{(content) => (
<div data-slot="session-timeline-notice" class="w-full px-4 pt-3 pb-1 md:px-5 text-13-regular">
<div data-slot="session-timeline-notice" class={`w-full pt-3 pb-1 text-13-regular ${turnPadding()}`}>
<span class="text-13-medium text-text-strong">{content().label}</span>
<Show when={content().data}>{(data) => <span class="text-text-weak"> · {data()}</span>}</Show>
</div>
@@ -878,7 +1068,7 @@ function MessageTimelineView(
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={turnDividerRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
@@ -894,7 +1084,7 @@ function MessageTimelineView(
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={assistantPartRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
@@ -909,7 +1099,7 @@ function MessageTimelineView(
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={thinkingRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={props.data.showReasoningSummaries()}
@@ -922,7 +1112,7 @@ function MessageTimelineView(
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={retryRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
@@ -930,10 +1120,35 @@ function MessageTimelineView(
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
const canMove = () =>
props.data.newLayoutDesigns() &&
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
!workspaceSession() &&
props.workspaceMoveEligible &&
sync().project?.vcs === "git" &&
sessionStatus().type === "idle"
return (
<TimelineRowFrame row={diffSummaryRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineDiffSummaryRow
diffs={diffSummaryRow().diffs}
action={
<Show when={canMove() && sync().project}>
{(project) => (
<WorkspaceMoveAction
variant="inline"
eligible={props.workspaceMoveEligible}
sessionID={sessionID()!}
project={project()}
directory={sessionDirectory()}
messageID={diffSummaryRow().userMessageID}
dismissed={workspaceSuggestionDismissed()}
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
/>
)}
</Show>
}
/>
</div>
</TimelineRowFrame>
)
@@ -942,7 +1157,7 @@ function MessageTimelineView(
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={errorRow()}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<Card variant="error" class="error-card">
{errorRow().text}
</Card>
@@ -1024,7 +1239,7 @@ function MessageTimelineView(
}
return (
<div class="relative w-full h-full min-w-0">
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
@@ -1119,6 +1334,30 @@ function MessageTimelineView(
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show when={props.data.newLayoutDesigns()}>
<Show
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<IconV2 name="monitor" />
</span>
}
>
<TooltipV2
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
aria-label={sessionDirectory()}
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
>
<IconV2 name="workspace-isolated" />
</span>
</TooltipV2>
</Show>
</Show>
<Show when={parentID()}>
<button
type="button"
@@ -1203,6 +1442,47 @@ function MessageTimelineView(
placement="bottom"
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={props.data.newLayoutDesigns() && !parentID() && sync().project}>
{(project) => (
<KobaltePopover
open={summaryOpen()}
placement="bottom-end"
gutter={6}
onOpenChange={setSummary}
>
<KobaltePopover.Trigger
as={IconButtonV2}
icon={<IconV2 name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<KobaltePopover.Portal>
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={sync().data.vcs?.branch}
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
messageID={props.userMessages.at(-1)?.id}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
command.trigger("review.toggle")
}}
/>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)}
</Show>
<Show when={!parentID()}>
<Show
when={props.data.newLayoutDesigns()}
@@ -32,8 +32,8 @@ export function createTimelineProjection(input: {
})
return result
})
const projection = createMemo(() =>
Timeline.constructSessionMessageRows(
const projection = createMemo(() => {
return Timeline.constructSessionMessageRows(
input.sessionMessages(),
(messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined,
input.parts,
@@ -41,8 +41,8 @@ export function createTimelineProjection(input: {
input.status().type,
input.inlineComments(),
input.userMessages(),
),
)
)
})
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),
@@ -43,7 +43,6 @@ export namespace TimelineRow {
export class Retry extends Data.TaggedClass("Retry")<{
userMessageID: string
}> {}
export type TimelineRow =
| TurnGap
| CommentStrip
@@ -13,10 +13,8 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { showToast } from "@/utils/toast"
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
import { findLast } from "@opencode-ai/core/util/array"
import { extractPromptFromParts } from "@/utils/prompt"
import type { UserMessage } from "@/types"
import { useLocal } from "@/context/local"
import type { SessionController } from "./session-controller"
type SessionCommandSource = {
@@ -58,7 +56,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const local = useLocal()
const navigate = useNavigate()
const openDialog = async <T,>(load: () => Promise<T>, show: (value: T) => void) => {
const owner = actions.session.ownership.capture()
@@ -364,6 +361,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
}
const fork = () => {
const sessionID = actions.session.identity.params.id
if (!sessionID) return
void openDialog(
() => import("@/components/dialog-fork"),
(x) => dialog.show(() => <x.DialogFork />),
@@ -0,0 +1,27 @@
import { createMemo, Show } from "solid-js"
import { useParams } from "@solidjs/router"
import { useGlobal } from "@/context/global"
import { ServerConnection } from "@/context/server"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { requireServerKey } from "@/utils/session-route"
import { TargetSessionRouteContent } from "./session"
export default function TargetSessionRoute() {
const params = useParams<{ serverKey: string }>()
const global = useGlobal()
const connection = createMemo(() => {
const key = requireServerKey(params.serverKey)
return global.servers.list().find((item) => ServerConnection.key(item) === key)
})
return (
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={connection()}>
<ServerSyncProvider server={connection()}>
<TargetSessionRouteContent />
</ServerSyncProvider>
</ServerSDKProvider>
</Show>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import {
filterWorkspaceInventory,
inspectWorkspaceDeletion,
isWorkspaceDirectory,
isWorkspaceSelection,
mergeWorkspaceSessionInventory,
sessionsForWorkspace,
workspaceInventory,
} from "./workspace"
describe("isWorkspaceDirectory", () => {
const project = {
worktree: "C:\\repo\\",
sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"],
}
test("distinguishes managed workspaces from the local repository", () => {
expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false)
expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true)
expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true)
expect(
isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"),
).toBe(true)
expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false)
expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false)
})
})
describe("isWorkspaceSelection", () => {
const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] }
test("accepts local, new, and managed workspace selections", () => {
expect(isWorkspaceSelection(project, "main")).toBe(true)
expect(isWorkspaceSelection(project, "create")).toBe(true)
expect(isWorkspaceSelection(project, "/repo/")).toBe(true)
expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true)
expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true)
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
})
})
test("groups and filters workspace inventory by project", () => {
const inventory = workspaceInventory([
{ id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] },
{ id: "b", worktree: "/b", sandboxes: ["/b/one"] },
])
expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([
["a", "/a/one"],
["a", "/a/two"],
["b", "/b/one"],
])
expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"])
expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory)
})
test("blocks unsafe workspace deletion", () => {
const session = (directory: string) =>
({ location: { directory }, time: { created: 1, updated: 1 } }) as SessionInfo
expect(
inspectWorkspaceDeletion({
workspace: "/workspace",
activeDirectory: "/workspace/app",
sessions: [],
status: "dirty",
}),
).toBe("active")
expect(
inspectWorkspaceDeletion({
workspace: "/workspace",
sessions: [session("/workspace/packages/app")],
status: "dirty",
}),
).toBe("linked")
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty")
expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe")
expect(
inspectWorkspaceDeletion({
workspace: "/workspace",
sessions: [
{ location: { directory: "/workspace" }, time: { created: 1, updated: 1, archived: 2 } } as SessionInfo,
],
status: "clean",
}),
).toBe("safe")
})
test("groups nested non-archived workspace sessions by latest activity", () => {
const session = (id: string, directory: string, updated: number, archived?: number) =>
({ id, location: { directory }, time: { created: 1, updated, archived } }) as SessionInfo
const sessions = sessionsForWorkspace(
[
session("old", "/workspace", 2),
session("nested", "/workspace/packages/app", 3),
session("archived", "/workspace", 4, 5),
session("other", "/other", 6),
],
"/workspace",
)
expect(sessions.map((item) => item.id)).toEqual(["nested", "old"])
})
test("merges workspace placement by freshness with authoritative server ties", () => {
const session = (directory: string, updated: number) =>
({ id: "session", location: { directory }, time: { created: 1, updated } }) as SessionInfo
expect(
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.location.directory,
).toBe("/destination")
expect(
mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.location.directory,
).toBe("/destination")
expect(
mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.location.directory,
).toBe("/source")
})
+100
View File
@@ -0,0 +1,100 @@
import { pathKey } from "@/utils/path-key"
import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings"
import type { SessionInfo } from "@opencode-ai/client/promise"
type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
export function workspaceDirectories(project: WorkspaceProject) {
return (project.sandboxes ?? []).filter(
(directory) => !containsDirectory(project.worktree, directory) || !containsDirectory(directory, project.worktree),
)
}
export function workspaceInventory<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project })))
}
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
workspaces: readonly T[],
project: string,
) {
if (project === "all") return [...workspaces]
return workspaces.filter((workspace) => workspace.project.id === project)
}
export function sessionsForWorkspace(sessions: readonly SessionInfo[], workspace: string) {
return sessions
.filter((session) => session.time.archived === undefined)
.filter((session) => containsDirectory(workspace, session.location.directory))
.toSorted((a, b) => b.time.updated - a.time.updated)
}
export function mergeWorkspaceSessionInventory(server: readonly SessionInfo[], cached: readonly SessionInfo[]) {
const sessions = new Map(server.map((session) => [session.id, session]))
cached.forEach((session) => {
const current = sessions.get(session.id)
if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session)
})
return [...sessions.values()]
}
export function removeWorkspacesSequentially<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve())
}
export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty"
export function inspectWorkspaceDeletion(input: {
workspace: string
activeDirectory?: string
sessions: readonly SessionInfo[]
status: "clean" | "dirty"
}): WorkspaceDeleteInspection {
if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active"
if (
input.sessions.some(
(session) =>
session.time.archived === undefined && containsDirectory(input.workspace, session.location.directory),
)
)
return "linked"
if (input.status === "dirty") return "dirty"
return "safe"
}
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
if (!project || (containsDirectory(project.worktree, directory) && containsDirectory(directory, project.worktree)))
return false
return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory))
}
export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) {
if (!project) return false
return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory))
}
export function containsDirectory(parent: string, child: string) {
const normalize = (value: string) => {
const key = pathKey(value)
return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key
}
const root = normalize(parent)
const target = normalize(child)
return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`)
}
export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) {
if (selection === "main" || selection === "create") return true
if (!project) return false
if (containsDirectory(project.worktree, selection) && containsDirectory(selection, project.worktree)) return true
return isWorkspaceDirectory(project, selection)
}
export function workspaceDefaultSelection(
setting: WorkspaceDefaultDestination,
lastUsed: WorkspaceLastUsed | undefined,
) {
if (setting === "local") return "main"
if (setting === "new") return "create"
return lastUsed === "workspace" ? "create" : "main"
}
+1 -1
View File
@@ -42,8 +42,8 @@
"solid-js": "catalog:",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"web-tree-sitter": "0.25.10",
"uqr": "0.1.3",
"web-tree-sitter": "0.25.10",
"ws": "8.21.0"
},
"devDependencies": {
+2 -2
View File
@@ -19,7 +19,7 @@ import { Permission } from "@opencode-ai/schema/permission"
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
import { Plugin } from "@opencode-ai/schema/plugin"
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
import { PromptInput } from "@opencode-ai/schema/prompt-input"
import { Provider } from "@opencode-ai/schema/provider"
@@ -60,7 +60,7 @@ const effectTypeReferences = [
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
...namespaceTypes("Worktree", "@opencode-ai/schema/worktree", Worktree),
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
+29 -22
View File
@@ -34,7 +34,7 @@ import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
import type { Pty } from "@opencode-ai/schema/pty"
import type { Question } from "@opencode-ai/schema/question"
import type { Reference } from "@opencode-ai/schema/reference"
import type { Worktree } from "@opencode-ai/schema/worktree"
import type { ProjectCopy } from "@opencode-ai/schema/project-copy"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { WebSearch } from "@opencode-ai/schema/websearch"
@@ -1230,9 +1230,17 @@ export type Endpoint13_1Input = {
export type Endpoint13_1Output = Project.Current
export type ProjectCurrentOperation<E = never> = (input?: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
export type Endpoint13_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint13_2Output = Project.Directories
export type ProjectDirectoriesOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly current: ProjectCurrentOperation<E>
readonly directories: ProjectDirectoriesOperation<E>
}
export type Endpoint14_0Input = {
@@ -1541,37 +1549,36 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type Endpoint24_0Input = { readonly projectID: Project.ID }
export type Endpoint24_0Output = Worktree.List
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_1Input = {
export type Endpoint24_0Input = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy: ProjectCopy.StrategyID
readonly directory: AbsolutePath
readonly name?: string | undefined
}
export type Endpoint24_1Output = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_0Output = ProjectCopy.Copy
export type ProjectCopyCreateOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
export type Endpoint24_2Input = {
export type Endpoint24_1Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
export type Endpoint24_1Output = void
export type ProjectCopyRemoveOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
export type Endpoint24_2Input = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type Endpoint24_2Output = void
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type ProjectCopyRefreshOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
export type Endpoint24_3Input = { readonly projectID: Project.ID }
export type Endpoint24_3Output = void
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
export interface WorktreeApi<E = never> {
readonly list: WorktreeListOperation<E>
readonly create: WorktreeCreateOperation<E>
readonly remove: WorktreeRemoveOperation<E>
readonly refresh: WorktreeRefreshOperation<E>
export interface ProjectCopyApi<E = never> {
readonly create: ProjectCopyCreateOperation<E>
readonly remove: ProjectCopyRemoveOperation<E>
readonly refresh: ProjectCopyRefreshOperation<E>
}
export type Endpoint25_0Input = {
@@ -1684,7 +1691,7 @@ export interface AppApi<E = never> {
readonly shell: ShellApi<E>
readonly question: QuestionApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly debug: DebugApi<E>
readonly migration: MigrationApi<E>
+34 -23
View File
@@ -139,6 +139,8 @@ import type {
Endpoint13_0Output,
Endpoint13_1Input,
Endpoint13_1Output,
Endpoint13_2Input,
Endpoint13_2Output,
Endpoint14_0Input,
Endpoint14_0Output,
Endpoint14_1Input,
@@ -214,8 +216,6 @@ import type {
Endpoint24_1Output,
Endpoint24_2Input,
Endpoint24_2Output,
Endpoint24_3Input,
Endpoint24_3Output,
Endpoint25_0Input,
Endpoint25_0Output,
Endpoint25_1Input,
@@ -876,7 +876,19 @@ const Endpoint13_1 = (raw: RawClient["server.project"]) => (input?: Endpoint13_1
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup13 = (raw: RawClient["server.project"]) => ({ list: Endpoint13_0(raw), current: Endpoint13_1(raw) })
const Endpoint13_2 = (raw: RawClient["server.project"]) => (input: Endpoint13_2Input) =>
preserveEffect<Endpoint13_2Output>()(
raw["project.directories"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup13 = (raw: RawClient["server.project"]) => ({
list: Endpoint13_0(raw),
current: Endpoint13_1(raw),
directories: Endpoint13_2(raw),
})
const Endpoint14_0 = (raw: RawClient["server.form"]) => (input?: Endpoint14_0Input) =>
preserveEffect<Endpoint14_0Output>()(
@@ -1200,37 +1212,36 @@ const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
const Endpoint24_0 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_0Input) =>
preserveEffect<Endpoint24_0Output>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["worktree.create"]({
raw["projectCopy.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
query: { location: input["location"] },
payload: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["worktree.remove"]({
const Endpoint24_1 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_1Input) =>
preserveEffect<Endpoint24_1Output>()(
raw["projectCopy.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
preserveEffect<Endpoint24_3Output>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
const Endpoint24_2 = (raw: RawClient["server.projectCopy"]) => (input: Endpoint24_2Input) =>
preserveEffect<Endpoint24_2Output>()(
raw["projectCopy.refresh"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
list: Endpoint24_0(raw),
create: Endpoint24_1(raw),
remove: Endpoint24_2(raw),
refresh: Endpoint24_3(raw),
const adaptGroup24 = (raw: RawClient["server.projectCopy"]) => ({
create: Endpoint24_0(raw),
remove: Endpoint24_1(raw),
refresh: Endpoint24_2(raw),
})
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
@@ -1323,7 +1334,7 @@ const adaptClient = (raw: RawClient) => ({
shell: adaptGroup21(raw["server.shell"]),
question: adaptGroup22(raw["server.question"]),
reference: adaptGroup23(raw["server.reference"]),
worktree: adaptGroup24(raw["server.worktree"]),
projectCopy: adaptGroup24(raw["server.projectCopy"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
migration: adaptGroup27(raw["server.migration"]),
+1 -1
View File
@@ -33,7 +33,7 @@ export { Model } from "@opencode-ai/schema/model"
export { Permission } from "@opencode-ai/schema/permission"
export { PermissionSaved } from "@opencode-ai/schema/permission-saved"
export { Project } from "@opencode-ai/schema/project"
export { Worktree } from "@opencode-ai/schema/worktree"
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
export { Provider } from "@opencode-ai/schema/provider"
export { Pty } from "@opencode-ai/schema/pty"
export { Question } from "@opencode-ai/schema/question"
+34 -35
View File
@@ -133,6 +133,8 @@ import type {
ProjectListOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
ProjectDirectoriesInput,
ProjectDirectoriesOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
@@ -204,14 +206,12 @@ import type {
QuestionRejectOutput,
ReferenceListInput,
ReferenceListOutput,
WorktreeListInput,
WorktreeListOutput,
WorktreeCreateInput,
WorktreeCreateOutput,
WorktreeRemoveInput,
WorktreeRemoveOutput,
WorktreeRefreshInput,
WorktreeRefreshOutput,
ProjectCopyCreateInput,
ProjectCopyCreateOutput,
ProjectCopyRemoveInput,
ProjectCopyRemoveOutput,
ProjectCopyRefreshInput,
ProjectCopyRefreshOutput,
VcsGetInput,
VcsGetOutput,
VcsStatusInput,
@@ -1255,6 +1255,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
directories: (input: ProjectDirectoriesInput, requestOptions?: RequestOptions) =>
request<ProjectDirectoriesOutput>(
{
method: "GET",
path: `/api/project/${encodeURIComponent(input.projectID)}/directories`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
form: {
request: {
@@ -1724,40 +1736,26 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
worktree: {
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
projectCopy: {
create: (input: ProjectCopyCreateInput, requestOptions?: RequestOptions) =>
request<ProjectCopyCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
body: {
strategy: input["strategy"],
from: input["from"],
directory: input["directory"],
name: input["name"],
},
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { strategy: input["strategy"], directory: input["directory"], name: input["name"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
remove: (input: WorktreeRemoveInput, requestOptions?: RequestOptions) =>
request<WorktreeRemoveOutput>(
remove: (input: ProjectCopyRemoveInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1765,11 +1763,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
refresh: (input: ProjectCopyRefreshInput, requestOptions?: RequestOptions) =>
request<ProjectCopyRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/copy/refresh`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+44 -49
View File
@@ -291,6 +291,8 @@ export type ProjectTime = { created: number; updated: number; initialized?: numb
export type ProjectCurrent = { id: string; directory: string; canonical: string }
export type ProjectDirectory = { directory: string; strategy?: string }
export type FormMetadata = { [x: string]: JsonValue }
export type FormValue = string | number | boolean | Array<string>
@@ -368,9 +370,7 @@ export type ReferenceGitSource = {
hidden?: boolean
}
export type WorktreeDirectory = { directory: string; strategy?: string }
export type WorktreeInfo = { directory: string }
export type ProjectCopyCopy = { directory: string }
export type VcsBranch = { current?: string; default?: string }
@@ -828,20 +828,20 @@ export type PluginUpdated = {
data: {}
}
export type WorktreeUpdated = {
export type ProjectDirectoriesUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "worktree.updated"
type: "project.directories.updated"
location?: LocationRef
data: { projectID: string }
}
export type WorktreeResolved = {
export type ProjectDirectoryResolved = {
id: string
created: number
metadata?: { [x: string]: any }
type: "worktree.resolved"
type: "project.directory.resolved"
durable: { aggregateID: string; seq: number; version: 1 }
location?: LocationRef
data: { projectID: string; directory: string; previous: string }
@@ -1369,6 +1369,8 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectDirectories = Array<ProjectDirectory>
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1518,8 +1520,6 @@ export type SessionStatus2 = {
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
export type WorktreeList = Array<WorktreeDirectory>
export type VcsInfo = { branch: VcsBranch }
export type PermissionRuleset = Array<PermissionRule>
@@ -2102,8 +2102,8 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| WorktreeUpdated
| WorktreeResolved
| ProjectDirectoriesUpdated
| ProjectDirectoryResolved
| CommandUpdated
| ConfigUpdated
| SkillUpdated
@@ -2304,12 +2304,12 @@ export type QuestionNotFoundError = {
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
export type WorktreeError = {
readonly name: "WorktreeError"
export type ProjectCopyError = {
readonly name: "ProjectCopyError"
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
}
export const isWorktreeError = (value: unknown): value is WorktreeError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
export const isProjectCopyError = (value: unknown): value is ProjectCopyError =>
typeof value === "object" && value !== null && "name" in value && value["name"] === "ProjectCopyError"
export type HealthGetOutput = ServiceHealth
@@ -4348,6 +4348,15 @@ export type ProjectCurrentInput = {
export type ProjectCurrentOutput = ProjectCurrent
export type ProjectDirectoriesInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type ProjectDirectoriesOutput = ProjectDirectories
export type FormRequestListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -5633,51 +5642,37 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
export type ProjectCopyCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly strategy: {
readonly strategy: string
readonly from?: string
readonly directory: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy: string
readonly from?: string
readonly directory: string
readonly name?: string
}["from"]
readonly directory: {
readonly strategy: string
readonly from?: string
readonly directory: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy: string
readonly from?: string
readonly directory: string
readonly name?: string
}["name"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy: { readonly strategy: string; readonly directory: string; readonly name?: string }["strategy"]
readonly directory: { readonly strategy: string; readonly directory: string; readonly name?: string }["directory"]
readonly name?: { readonly strategy: string; readonly directory: string; readonly name?: string }["name"]
}
export type WorktreeCreateOutput = WorktreeInfo
export type ProjectCopyCreateOutput = ProjectCopyCopy
export type WorktreeRemoveInput = {
export type ProjectCopyRemoveInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
readonly force: { readonly directory: string; readonly force: boolean }["force"]
}
export type WorktreeRemoveOutput = void
export type ProjectCopyRemoveOutput = void
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type ProjectCopyRefreshInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshOutput = void
export type ProjectCopyRefreshOutput = void
export type VcsGetInput = {
readonly location?: {
+18 -37
View File
@@ -29,7 +29,7 @@ test("exposes every standard HTTP API group", () => {
"shell",
"question",
"reference",
"worktree",
"projectCopy",
"vcs",
"debug",
"migration",
@@ -49,8 +49,7 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.vcs)).toEqual(["get", "status", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "current"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
})
test("config.get returns ordered config entries for a location", async () => {
@@ -252,48 +251,30 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("worktree methods use the global project contract", async () => {
const requests: Request[] = []
test("project methods use the public HTTP contract", async () => {
const requests: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/tmp/project" }])
if (request.method === "POST" && !request.url.endsWith("/refresh"))
return Response.json({ directory: "/tmp/worktrees/api" })
return new Response(null, { status: 204 })
fetch: async (input) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
requests.push(url)
if (url.includes("/directories")) return Response.json([])
return Response.json({ id: "proj_test", directory: "/tmp/project" })
},
})
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(
await client.worktree.create({
projectID: "proj_test",
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
}),
).toEqual({ directory: "/tmp/worktrees/api" })
await client.worktree.remove({
projectID: "proj_test",
directory: "/tmp/worktrees/api",
force: false,
const current = await client.project.current({ location: { workspace: "wrk_test" } })
const directories = await client.project.directories({
projectID: current.id,
location: { directory: current.directory },
})
await client.worktree.refresh({ projectID: "proj_test" })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
expect(current).toEqual({ id: "proj_test", directory: "/tmp/project" })
expect(directories).toEqual([])
expect(requests).toEqual([
"http://localhost:3000/api/project/current?location%5Bworkspace%5D=wrk_test",
"http://localhost:3000/api/project/proj_test/directories?location%5Bdirectory%5D=%2Ftmp%2Fproject",
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
directory: "/tmp/worktrees",
name: "api",
})
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("shell list and remove use the public HTTP contract", async () => {
-2
View File
@@ -79,7 +79,6 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
@@ -137,7 +136,6 @@
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"mime-types": "3.0.2",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"venice-ai-sdk-provider": "2.1.1",
+41 -191
View File
@@ -1,10 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "dcde8e6b-4bf4-4f6b-b2be-4030c2c3e936",
"prevIds": [
"5c1aa56b-c3ee-4283-9a84-c0bf626dc604"
],
"id": "5c1aa56b-c3ee-4283-9a84-c0bf626dc604",
"prevIds": ["00924d88-1842-4d71-ac74-5682ddc47e1c"],
"ddl": [
{
"name": "account_state",
@@ -78,10 +76,6 @@
"name": "workspace",
"entityType": "tables"
},
{
"name": "worktree",
"entityType": "tables"
},
{
"type": "integer",
"notNull": false,
@@ -1443,53 +1437,9 @@
"table": "workspace"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "project_id",
"entityType": "columns",
"table": "worktree"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "directory",
"entityType": "columns",
"table": "worktree"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "strategy",
"entityType": "columns",
"table": "worktree"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_created",
"entityType": "columns",
"table": "worktree"
},
{
"columns": [
"active_account_id"
],
"columns": ["active_account_id"],
"tableTo": "account",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "SET NULL",
"nameExplicit": false,
@@ -1498,13 +1448,9 @@
"table": "account_state"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"tableTo": "event_sequence",
"columnsTo": [
"aggregate_id"
],
"columnsTo": ["aggregate_id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1513,13 +1459,9 @@
"table": "event"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1528,13 +1470,9 @@
"table": "permission"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1543,13 +1481,9 @@
"table": "project_directory"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1558,13 +1492,9 @@
"table": "instruction_entry"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1573,13 +1503,9 @@
"table": "instruction_state"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1588,13 +1514,9 @@
"table": "session_inbox"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1603,13 +1525,9 @@
"table": "session_message"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"tableTo": "session_v2",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1618,13 +1536,9 @@
"table": "session_pending"
},
{
"columns": [
"project_id"
],
"columns": ["project_id"],
"tableTo": "project",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
@@ -1633,190 +1547,126 @@
"table": "session_v2"
},
{
"columns": [
"project_id"
],
"tableTo": "project",
"columnsTo": [
"id"
],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_worktree_project_id_project_id_fk",
"entityType": "fks",
"table": "worktree"
},
{
"columns": [
"email",
"url"
],
"columns": ["email", "url"],
"nameExplicit": false,
"name": "control_account_pk",
"entityType": "pks",
"table": "control_account"
},
{
"columns": [
"project_id",
"directory"
],
"columns": ["project_id", "directory"],
"nameExplicit": false,
"name": "project_directory_pk",
"entityType": "pks",
"table": "project_directory"
},
{
"columns": [
"session_id",
"key"
],
"columns": ["session_id", "key"],
"nameExplicit": false,
"name": "instruction_entry_pk",
"entityType": "pks",
"table": "instruction_entry"
},
{
"columns": [
"project_id",
"directory"
],
"nameExplicit": false,
"name": "worktree_pk",
"entityType": "pks",
"table": "worktree"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_state_pk",
"table": "account_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "account_pk",
"table": "account",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "credential_pk",
"table": "credential",
"entityType": "pks"
},
{
"columns": [
"aggregate_id"
],
"columns": ["aggregate_id"],
"nameExplicit": false,
"name": "event_sequence_pk",
"table": "event_sequence",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "event_pk",
"table": "event",
"entityType": "pks"
},
{
"columns": [
"key"
],
"columns": ["key"],
"nameExplicit": false,
"name": "kv_pk",
"table": "kv",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "permission_pk",
"table": "permission",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "project_pk",
"table": "project",
"entityType": "pks"
},
{
"columns": [
"hash"
],
"columns": ["hash"],
"nameExplicit": false,
"name": "instruction_blob_pk",
"table": "instruction_blob",
"entityType": "pks"
},
{
"columns": [
"session_id"
],
"columns": ["session_id"],
"nameExplicit": false,
"name": "instruction_state_pk",
"table": "instruction_state",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_inbox_pk",
"table": "session_inbox",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_message_pk",
"table": "session_message",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_pending_pk",
"table": "session_pending",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "session_v2_pk",
"table": "session_v2",
"entityType": "pks"
},
{
"columns": [
"id"
],
"columns": ["id"],
"nameExplicit": false,
"name": "workspace_pk",
"table": "workspace",
@@ -2112,4 +1962,4 @@
}
],
"renames": []
}
}
-2
View File
@@ -42,7 +42,6 @@ import m39 from "./migration/20260805200742_import_legacy_credentials.js"
import m40 from "./migration/20260808023530_workspace_domain.js"
import m41 from "./migration/20260811161259_execution_claim_attempts.js"
import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
export const migrations = [
m00,
@@ -88,5 +87,4 @@ export const migrations = [
m40,
m41,
m42,
m43,
] satisfies DatabaseMigration.Migration[]
@@ -1,35 +0,0 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
const migration: DatabaseMigration.Migration = {
id: "20260812213948_worktree",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
CREATE TABLE \`worktree\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
INSERT INTO \`worktree\` (\`project_id\`, \`directory\`, \`strategy\`, \`time_created\`)
SELECT
\`project_id\`,
\`directory\`,
CASE
WHEN \`strategy\` = 'git_worktree' THEN 'git'
WHEN \`strategy\` IS NOT NULL THEN \`strategy\`
WHEN \`type\` = 'git_worktree' THEN 'git'
END,
\`time_created\`
FROM \`project_directory\`;
`)
})
},
}
export default migration
-10
View File
@@ -225,16 +225,6 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`last_used_at\` integer NOT NULL
);
`)
yield* tx.run(`
CREATE TABLE \`worktree\` (
\`project_id\` text NOT NULL,
\`directory\` text NOT NULL,
\`strategy\` text,
\`time_created\` integer NOT NULL,
CONSTRAINT \`worktree_pk\` PRIMARY KEY(\`project_id\`, \`directory\`),
CONSTRAINT \`fk_worktree_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
yield* tx.run(
+3 -2
View File
@@ -25,7 +25,7 @@ import { MCP } from "./mcp/index.js"
import { Permission } from "./permission.js"
import { Plugin } from "./plugin.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { Worktree } from "./worktree.js"
import { ProjectCopy } from "./project/copy.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { Shell } from "./shell.js"
@@ -66,7 +66,8 @@ const locationServiceNodes = [
AISDK.node,
Plugin.node,
PluginSupervisor.node,
Worktree.refreshNode,
ProjectCopy.node,
ProjectCopy.refreshNode,
FileSystemSearch.node,
FileSystem.node,
Pty.node,
@@ -14,7 +14,7 @@ import { Money } from "@opencode-ai/schema/money"
import { ConfigProviderOptionsV1 } from "../../v1/config/provider-options.js"
import { ConfigV1 } from "../../v1/config/config.js"
const defaultServer = "https://console.opencode.ai"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
const methodID = Integration.MethodID.make("device")
const RemoteResponse = Schema.Struct({ config: ConfigV1.Info })
+25 -26
View File
@@ -2,20 +2,20 @@ export * as Project from "./project.js"
import { Context, Effect, Layer, Schema } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { and, asc, desc, eq } from "drizzle-orm"
import { asc, desc } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Event } from "@opencode-ai/schema/project-directories"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectDirectories } from "./project/directories.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
export const ID = ProjectSchema.ID
export type ID = ProjectSchema.ID
@@ -26,9 +26,18 @@ export type Vcs = ProjectSchema.Vcs
export const Current = ProjectSchema.Current
export type Current = ProjectSchema.Current
export const Directory = ProjectSchema.Directory
export type Directory = ProjectSchema.Directory
export const Info = ProjectSchema.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = ProjectSchema.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = ProjectSchema.Directories
export type Directories = typeof Directories.Type
export interface Resolved {
readonly previous?: ID
readonly id: ID
@@ -47,6 +56,7 @@ export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, i
export interface Interface {
readonly list: () => Effect.Effect<ReadonlyArray<Info>>
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
}
@@ -85,19 +95,18 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const projectDirectories = yield* ProjectDirectories.Service
const announcing = new Set<string>()
const persist = Effect.fnUntraced(function* (project: Resolved) {
yield* upsertProject(db, project).pipe(Effect.orDie)
if (!project.vcs) return project
const directories: Array<{ projectID: ID; directory: AbsolutePath; strategy?: string }> = [
{ projectID: project.id, directory: project.canonical },
]
const directories: ProjectDirectories.CreateInput[] = [{ projectID: project.id, directory: project.canonical }]
if (project.directory !== project.canonical)
directories.push({
projectID: project.id,
directory: project.directory,
strategy: project.vcs.type === "git" ? "git" : undefined,
strategy: project.vcs.type === "git" ? "git_worktree" : undefined,
})
// A missing directory row means this directory's resolution is a new durable
// fact (copy.ts registers copy directories directly; those never strand
@@ -110,25 +119,11 @@ const layer = Layer.effect(
if (announcing.has(key)) continue
announcing.add(key)
yield* Effect.gen(function* () {
const stored = yield* db
.select({ directory: WorktreeTable.directory })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, item.projectID), eq(WorktreeTable.directory, item.directory)))
.get()
.pipe(Effect.orDie)
if (stored) return
if (yield* projectDirectories.get({ projectID: item.projectID, directory: item.directory })) return
yield* bus.publish(
Worktree.Event.Resolved,
Event.Resolved,
{ projectID: item.projectID, directory: item.directory, previous: project.previous ?? ID.global },
{
commit: () =>
db
.insert(WorktreeTable)
.values({ project_id: item.projectID, directory: item.directory, strategy: item.strategy })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie, Effect.asVoid),
},
{ commit: () => Effect.asVoid(projectDirectories.create(item)) },
)
}).pipe(Effect.ensuring(Effect.sync(() => announcing.delete(key))))
}
@@ -145,6 +140,10 @@ const layer = Layer.effect(
return rows.map(fromRow)
})
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
return yield* projectDirectories.list(input.projectID)
})
const cached = Effect.fnUntraced(function* (dir: string) {
return yield* fs.readFileString(path.join(dir, "opencode")).pipe(
Effect.map((value) => value.trim()),
@@ -258,12 +257,12 @@ const layer = Layer.effect(
return yield* persist({ id: ID.global, directory, canonical: directory, vcs: undefined })
})
return Service.of({ list, resolve })
return Service.of({ list, directories, resolve })
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node, ProjectDirectories.node],
})
@@ -0,0 +1,35 @@
import { Effect } from "effect"
import { AbsolutePath } from "../schema.js"
import { Git } from "../git.js"
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy.js"
export function makeGitWorktreeStrategy(input: {
git: Git.Interface
canonical: (directory: AbsolutePath) => Effect.Effect<AbsolutePath, DirectoryUnavailableError>
}) {
return {
id: StrategyID.make("git_worktree"),
create: Effect.fn("ProjectCopy.GitWorktree.create")(function* (options) {
const repository = yield* input.git.repo.discover(options.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: options.sourceDirectory })
yield* input.git.worktree.create({ repository, directory: options.directory })
return { directory: yield* input.canonical(options.directory) }
}),
remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) {
const found = yield* input.git.repo.discover(options.directory)
if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory })
yield* input.git.worktree.remove({ repository: found, directory: options.directory, force: options.force })
}),
list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) {
const found = yield* input.git.repo.discover(directory)
if (!found) return yield* new DirectoryUnavailableError({ directory })
const entries = yield* input.git.worktree.list(found)
return yield* Effect.forEach(entries, (entry) =>
input.canonical(entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "copy" }) as const),
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
} satisfies Strategy
}
+272
View File
@@ -0,0 +1,272 @@
export * as ProjectCopy from "./copy.js"
import { Context, Effect, Layer, Schema } from "effect"
import path from "path"
import { AbsolutePath } from "../schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Project } from "../project.js"
import { ProjectDirectories } from "./directories.js"
import { makeGitWorktreeStrategy } from "./copy-strategies.js"
import { Slug } from "../util/slug.js"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Location } from "../location.js"
import { Event } from "@opencode-ai/schema/project-directories"
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
export const StrategyID = ProjectCopy.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = ProjectCopy.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = ProjectCopy.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: Project.ID,
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Copy = ProjectCopy.Copy
export type Copy = typeof Copy.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "copy"]),
}).annotate({ identifier: "ProjectCopy.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"ProjectCopy.SourceDirectoryNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"ProjectCopy.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"ProjectCopy.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"ProjectCopy.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"ProjectCopy.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Copy, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export { Event }
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const copies = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
const result = yield* copies.refresh({ projectID: location.project.id })
yield* Effect.logInfo("project copy refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const directories = yield* ProjectDirectories.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
const strategy = makeGitWorktreeStrategy({ git, canonical })
const source = Effect.fnUntraced(function* (input: AbsolutePath, projectID: Project.ID) {
const sourceDirectory = yield* canonical(input)
if ((yield* directories.get({ projectID, directory: sourceDirectory })) === undefined)
return yield* new SourceDirectoryNotFoundError({ directory: sourceDirectory })
return sourceDirectory
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
if (id !== strategy.id) return yield* new StrategyUnavailableError({ strategy: id })
return strategy
})
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let copyDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(copyDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: copyDirectory })
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: copyDirectory,
sourceDirectory,
})
yield* changed(
input.projectID,
yield* directories.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
behavior: "replace",
}),
)
return result
})
const remove = Effect.fn("ProjectCopy.remove")(function* (input: RemoveInput) {
const copyDirectory = yield* canonical(input.directory)
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: copyDirectory,
force: input.force,
})
yield* changed(
input.projectID,
yield* directories.remove({ projectID: input.projectID, directory: copyDirectory }),
)
})
const refresh = Effect.fn("ProjectCopy.refresh")(function* (input: RefreshInput) {
const stored = yield* directories.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "copy" ? strategy.id : undefined,
})),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat().map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
directories.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
behavior: "replace",
},
tx,
),
),
removed: Effect.forEach(removed, (directory) =>
directories.remove({ projectID: input.projectID, directory }, tx),
),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
create,
remove,
refresh,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, ProjectDirectories.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "project-copy-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
+128
View File
@@ -0,0 +1,128 @@
export * as ProjectDirectories from "./directories.js"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { AbsolutePath } from "../schema.js"
import { ProjectSchema } from "./schema.js"
import { ProjectDirectoryTable } from "./sql.js"
import type { EffectDrizzleSqlite } from "../database/drizzle.js"
import type { Project } from "../project.js"
export type Directory = Project.Directory
export const CreateInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
strategy: Schema.optional(Schema.String),
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
})
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Schema.Struct({
projectID: ProjectSchema.ID,
directory: AbsolutePath,
})
export type RemoveInput = typeof RemoveInput.Type
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ListInput = ProjectSchema.DirectoriesInput
export type ListInput = typeof ListInput.Type
export const ListOutput = ProjectSchema.Directories
export type ListOutput = typeof ListOutput.Type
export interface Interface {
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
readonly get: (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) => Effect.Effect<Directory | undefined>
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
const insert = (tx ?? db)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
const query =
input.behavior === "replace"
? insert.onConflictDoUpdate({
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
: isNotNull(ProjectDirectoryTable.strategy),
})
: insert.onConflictDoNothing()
return (
(yield* query.returning({ directory: ProjectDirectoryTable.directory }).get().pipe(Effect.orDie)) !== undefined
)
})
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.returning({ directory: ProjectDirectoryTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
})
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
})
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
projectID: ProjectSchema.ID
directory: AbsolutePath
}) {
const row = yield* db
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(
and(
eq(ProjectDirectoryTable.project_id, input.projectID),
eq(ProjectDirectoryTable.directory, input.directory),
),
)
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
})
return Service.of({
list,
get,
create,
remove,
})
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [Database.node] })
+9
View File
@@ -10,9 +10,18 @@ export type ID = typeof ID.Type
export const Current = Project.Current
export type Current = typeof Current.Type
export const Directory = Project.Directory
export type Directory = typeof Directory.Type
export const Info = Project.Info
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const DirectoriesInput = Project.DirectoriesInput
export type DirectoriesInput = typeof DirectoriesInput.Type
export const Directories = Project.Directories
export type Directories = typeof Directories.Type
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
-1
View File
@@ -23,7 +23,6 @@ export const ProjectTable = sqliteTable("project", {
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
/** @deprecated Use WorktreeTable from worktree/sql instead. */
export const ProjectDirectoryTable = sqliteTable(
"project_directory",
{
+2 -3
View File
@@ -18,7 +18,7 @@ import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Money } from "@opencode-ai/schema/money"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Event } from "@opencode-ai/schema/project-directories"
import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
@@ -436,13 +436,12 @@ const layer = Layer.effectDiscard(
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
// Sessions whose ownership came from the directory's previous resolution
// follow its new identity. Location, transcript, instructions, and recency
// are untouched: the session did not move, its directory got identified.
yield* bus.project(Worktree.Event.Resolved, (event) =>
yield* bus.project(Event.Resolved, (event) =>
Effect.gen(function* () {
const stale = [event.data.previous, Project.ID.global].filter((id) => id !== event.data.projectID)
if (stale.length === 0) return
+662
View File
@@ -0,0 +1,662 @@
import { Parser } from "htmlparser2"
const omitted = new Set(["script", "style", "noscript", "iframe", "object", "embed", "meta", "link", "template"])
const blocks = new Set([
"address",
"article",
"aside",
"details",
"dialog",
"div",
"dl",
"fieldset",
"figcaption",
"figure",
"footer",
"form",
"header",
"main",
"nav",
"p",
"section",
"summary",
])
type Frame = {
suppressed: boolean
link?: { href: string; title?: string }
suspendedLink?: Frame["link"]
marker?: { index: number; block: number; leadingSpace?: boolean; previous?: Frame["marker"] }
code?: { inline: boolean; text: string; language?: string }
linkCode?: NonNullable<Frame["code"]>
resumedCode?: NonNullable<Frame["code"]>
list?: { ordered: boolean; next: number; previous?: Frame["list"] }
item?: { indent: string; previous?: Frame["item"] }
table?: {
start: number
rows: string[][]
row?: string[]
caption?: string
fallback: boolean
previous?: Frame["table"]
}
cell?: { start: number }
caption?: { start: number }
details?: { open: boolean; summary: boolean; previous?: Frame["details"] }
}
type Chunk = string | { raw: string }
export const MAX_MARKDOWN_BYTES = 5 * 1024 * 1024
const CONTENT_BYTES = MAX_MARKDOWN_BYTES - 64 * 1024
export function convertHTMLToMarkdown(html: string) {
const output: Chunk[] = []
const stack: Frame[] = []
const encoder = new TextEncoder()
let pendingSpace = false
let pendingIndent = ""
let last = ""
let quoteDepth = 0
let needsQuotePrefix = false
let blockCount = 0
let depth = 0
let stopped = false
let outputBytes = 0
let activeCode: NonNullable<Frame["code"]> | undefined
let activeLink: Frame["link"] | undefined
let linkOpen = false
let activeMarker: Frame["marker"] | undefined
let activeList: Frame["list"] | undefined
let activeItem: Frame["item"] | undefined
let activeTable: NonNullable<Frame["table"]> | undefined
let activeCell: Frame["cell"] | undefined
let tableDepth = 0
let fallbackSuppressedDepth = 0
let fallbackOmittedDepth = 0
let activeDetails: Frame["details"] | undefined
const sliceBytes = (value: string, bytes: number) => {
if (encoder.encode(value).byteLength <= bytes) return value
const characters = Array.from(value)
let low = 0
let high = characters.length
while (low < high) {
const middle = Math.ceil((low + high) / 2)
if (encoder.encode(characters.slice(0, middle).join("")).byteLength <= bytes) low = middle
else high = middle - 1
}
return characters.slice(0, low).join("")
}
const append = (value: string, content = false) => {
const limit = content ? CONTENT_BYTES : MAX_MARKDOWN_BYTES
if (!value || outputBytes >= limit) return
const bytes = encoder.encode(value)
const remaining = limit - outputBytes
const next = bytes.byteLength <= remaining ? value : sliceBytes(value, remaining)
output.push(next)
outputBytes += encoder.encode(next).byteLength
last = next.at(-1) ?? last
}
const appendRaw = (value: string) => {
const before = output.length
append(value)
if (output.length > before) output[output.length - 1] = { raw: output[output.length - 1] as string }
}
const take = (start: number) => {
const chunks = output.splice(start)
const value = chunks.map((chunk) => (typeof chunk === "string" ? chunk : chunk.raw)).join("")
outputBytes -= encoder.encode(value).byteLength
return value
}
const prefixQuote = () => {
if (!needsQuotePrefix || quoteDepth === 0 || activeCell) return
append(`${"> ".repeat(Math.min(8, quoteDepth))}`)
needsQuotePrefix = false
}
const flushSpace = () => {
if (!pendingSpace) return
const marker = activeMarker
if (marker && output.length === marker.index + 1 && last !== " " && last !== "\n") {
const value = output[marker.index]
if (typeof value === "string") output[marker.index] = ` ${value}`
outputBytes++
marker.leadingSpace = true
pendingSpace = false
return
}
if (last && last !== "\n" && last !== " ") append(" ")
pendingSpace = false
}
const inline = (value: string, open = false) => {
if (open) flushSpace()
prefixQuote()
if (pendingIndent) {
append(pendingIndent)
pendingIndent = ""
}
if (activeLink && !linkOpen) {
append("[")
linkOpen = true
}
append(value)
}
const block = () => {
if (activeLink && linkOpen) {
append(`](${destination(activeLink.href)}${title(activeLink.title)})`)
linkOpen = false
}
pendingSpace = false
append("\n\n")
blockCount++
needsQuotePrefix = quoteDepth > 0
pendingIndent = activeItem?.indent ?? ""
}
const suspendLink = (frame: Frame) => {
if (!activeLink) return
frame.suspendedLink = activeLink
block()
activeLink = undefined
linkOpen = false
}
const text = (value: string) => {
if (activeCode) {
activeCode.text += value
return
}
for (const part of value.split(/([\t\n\f\r ]+)/)) {
if (!part) continue
if (/^[\t\n\f\r ]+$/.test(part)) {
pendingSpace = true
continue
}
flushSpace()
prefixQuote()
if (pendingIndent) {
append(pendingIndent)
pendingIndent = ""
}
if (activeLink && !linkOpen) {
append("[")
linkOpen = true
}
const escaped = part
.replace(/([\\`*_[\]<>|])/g, "\\$1")
.replace(/~/g, "\\~")
.replace(/^([#+-])/, "\\$1")
.replace(/^(\d+)\./, "$1\\.")
append(escaped, true)
}
}
const destination = (value: string) => value.replace(/([\\()])/g, "\\$1").replace(/[\t\n\r ]+/g, "%20")
const title = (value: string | undefined) =>
value
? ` "${value
.replace(/[\t\n\r ]+/g, " ")
.trim()
.replace(/([\\"])/g, "\\$1")}"`
: ""
const finishCode = (code: NonNullable<Frame["code"]>) => {
if (code.inline && !code.text) return
let backticks = 0
let tildes = 0
let currentBackticks = 0
let currentTildes = 0
for (const character of code.text) {
currentBackticks = character === "`" ? currentBackticks + 1 : 0
currentTildes = character === "~" ? currentTildes + 1 : 0
backticks = Math.max(backticks, currentBackticks)
tildes = Math.max(tildes, currentTildes)
}
if (code.inline) {
const fence = "`".repeat(Math.max(1, backticks + 1))
const padding = /^ | $/.test(code.text) && !/^ +$/.test(code.text) ? " " : ""
flushSpace()
prefixQuote()
const wrapper = encoder.encode(`${fence}${padding}${padding}${fence}`).byteLength
appendRaw(
`${fence}${padding}${sliceBytes(code.text, Math.max(0, CONTENT_BYTES - outputBytes - wrapper))}${padding}${fence}`,
)
return
}
if (activeCell) {
text(code.text)
return
}
const marker = backticks <= tildes ? "`" : "~"
const length = Math.max(3, (marker === "`" ? backticks : tildes) + 1)
const fence = marker.repeat(length)
block()
const prefix = `${fence}${code.language ?? ""}\n`
const quote = quoteDepth > 0 ? `${"> ".repeat(Math.min(8, quoteDepth))}` : ""
const closing = `${code.text.endsWith("\n") ? "" : "\n"}${fence}`
let payload = code.text
for (;;) {
const candidate = `${prefix}${payload}${payload.endsWith("\n") ? "" : "\n"}${fence}`
const value = quote ? candidate.replace(/^/gm, quote) : candidate
const valueBytes = encoder.encode(value).byteLength
if (outputBytes + valueBytes <= CONTENT_BYTES) {
appendRaw(value)
block()
return
}
const excess = valueBytes - Math.max(0, CONTENT_BYTES - outputBytes)
payload = sliceBytes(payload, Math.max(0, encoder.encode(payload).byteLength - Math.ceil(excess)))
}
}
const parser = new Parser({
onopentag(name, attributes) {
depth++
if (depth > 10_000) {
if (stack.at(-1)?.suppressed) {
const visibleParent = stack.findLastIndex((frame) => !frame.suppressed)
fallbackSuppressedDepth = visibleParent + 2
}
activeCode = undefined
stopped = true
}
if (stopped) {
if (omitted.has(name)) fallbackOmittedDepth++
else pendingSpace = true
return
}
const suppressed = (stack.at(-1)?.suppressed ?? false) || omitted.has(name)
const frame: Frame = { suppressed }
const hidden = "hidden" in attributes || attributes["aria-hidden"]?.toLowerCase() === "true" || name === "head"
const details = activeDetails
if (hidden || (details && !details.open && !details.summary && name !== "summary")) frame.suppressed = true
stack.push(frame)
if (frame.suppressed) return
if (activeCode && !activeCode.inline) {
if (name === "br") activeCode.text += "\n"
if (name === "code" && attributes.class)
activeCode.language = attributes.class.match(/(?:language-|lang-)([^\s]+)/)?.[1]
return
}
if (name === "details") {
frame.details = { open: "open" in attributes, summary: false, previous: activeDetails }
activeDetails = frame.details
block()
return
}
if (name === "summary") {
if (details) details.summary = true
block()
return
}
if (name === "pre") {
suspendLink(frame)
frame.code = { inline: false, text: "" }
activeCode = frame.code
return
}
if (name === "code") {
if (activeCode?.inline) return
frame.code = { inline: true, text: "" }
activeCode = frame.code
return
}
if (/^h[1-6]$/.test(name)) {
block()
inline(`${"#".repeat(Number(name[1]))} `)
return
}
if (blocks.has(name)) {
suspendLink(frame)
if (name === "p" && last === " ") return
block()
return
}
if (name === "br") {
pendingSpace = false
inline(" \n")
needsQuotePrefix = quoteDepth > 0
return
}
if (name === "hr") {
block()
inline("---")
block()
return
}
if (name === "strong" || name === "b") {
inline("**", true)
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
activeMarker = frame.marker
return
}
if (name === "em" || name === "i") {
inline("*", true)
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
activeMarker = frame.marker
return
}
if (name === "s" || name === "strike" || name === "del") {
inline("~~", true)
frame.marker = { index: output.length - 1, block: blockCount, previous: activeMarker }
activeMarker = frame.marker
return
}
if (name === "a") {
if (activeLink && activeCode?.inline) {
const parent = stack.findLast(
(candidate) => candidate.link === activeLink && candidate.linkCode === activeCode,
)
if (parent) {
finishCode(activeCode)
if (linkOpen) append(`](${destination(activeLink.href)}${title(activeLink.title)})`)
activeCode = parent.resumedCode
parent.link = undefined
parent.linkCode = undefined
activeLink = undefined
linkOpen = false
}
}
if (activeCode?.inline) {
frame.resumedCode = activeCode
if (activeCode.text) finishCode(activeCode)
activeCode.text = ""
activeCode = undefined
frame.link = { href: attributes.href ?? "", title: attributes.title }
activeLink = frame.link
linkOpen = true
inline("[", true)
frame.linkCode = { inline: true, text: "" }
activeCode = frame.linkCode
return
}
if (activeLink) {
if (linkOpen) append(`](${destination(activeLink.href)}${title(activeLink.title)})`)
const parent = stack.findLast((candidate) => candidate.link === activeLink)
if (parent) parent.link = undefined
activeLink = undefined
linkOpen = false
}
frame.link = { href: attributes.href ?? "", title: attributes.title }
activeLink = frame.link
linkOpen = true
return inline("[", true)
}
if (name === "img") {
const alt = (attributes.alt ?? "").replace(/([\\\]])/g, "\\$1")
const close = `](${destination(attributes.src ?? "")}${title(attributes.title)})`
const open = "!["
const available = CONTENT_BYTES - outputBytes - encoder.encode(open + close).byteLength
inline(`${open}${sliceBytes(alt, Math.max(0, available))}${close}`, true)
return
}
if (name === "blockquote") {
suspendLink(frame)
block()
quoteDepth++
needsQuotePrefix = true
return
}
if (name === "ul" || name === "ol") {
suspendLink(frame)
const start = Number.parseInt(attributes.start ?? "1")
frame.list = { ordered: name === "ol", next: Number.isNaN(start) ? 1 : start, previous: activeList }
activeList = frame.list
block()
return
}
if (name === "li") {
block()
const value = Number.parseInt(attributes.value ?? "")
if (activeList?.ordered && !Number.isNaN(value)) activeList.next = value
const marker = activeList?.ordered ? `${activeList.next++}.` : "-"
const prefix = `${(activeItem?.indent ?? "").slice(0, 24)}${marker} `
frame.item = { indent: " ".repeat(prefix.length), previous: activeItem }
activeItem = frame.item
pendingIndent = ""
inline(prefix)
return
}
if (name === "table") {
suspendLink(frame)
tableDepth++
if (tableDepth === 1) {
block()
frame.table = { start: output.length, rows: [], fallback: false, previous: activeTable }
activeTable = frame.table
} else pendingSpace = true
return
}
if (name === "tr") {
if (tableDepth !== 1) {
pendingSpace = true
return
}
if (activeTable) activeTable.row = []
return
}
if (name === "th" || name === "td") {
if (tableDepth !== 1) {
pendingSpace = true
return
}
if (attributes.colspan || attributes.rowspan) activeTable!.fallback = true
frame.cell = { start: output.length }
activeCell = frame.cell
return
}
if (name === "caption") {
frame.caption = { start: output.length }
return
}
if (name === "dt") {
block()
inline("**")
return
}
if (name === "dd") {
inline("\n: ")
return
}
},
ontext(value) {
if (stopped) {
if (fallbackSuppressedDepth === 0 && fallbackOmittedDepth === 0) text(value)
return
}
if (stack.at(-1)?.suppressed) return
text(value)
},
onclosetag(name) {
depth--
if (stopped) {
if (fallbackOmittedDepth > 0 && omitted.has(name)) fallbackOmittedDepth--
if (fallbackSuppressedDepth > 0 && depth < fallbackSuppressedDepth) fallbackSuppressedDepth = 0
return
}
const frame = stack.pop()
if (!frame || frame.suppressed) return
if (frame.linkCode) {
if (activeLink !== frame.link || !linkOpen) {
frame.resumedCode!.text += frame.linkCode.text
activeCode = frame.resumedCode
if (activeLink === frame.link) activeLink = undefined
linkOpen = false
return
}
activeCode = undefined
finishCode(frame.linkCode)
if (frame.link && linkOpen) append(`](${destination(frame.link.href)}${title(frame.link.title)})`)
activeLink = undefined
linkOpen = false
activeCode = frame.resumedCode
return
}
if (activeCode && !activeCode.inline && !frame.code) return
if (frame.code) {
activeCode = undefined
finishCode(frame.code)
if (frame.suspendedLink) activeLink = frame.suspendedLink
return
}
if (name === "summary") {
if (activeDetails) activeDetails.summary = false
return block()
}
if (name === "details") {
activeDetails = frame.details?.previous
return block()
}
if (name === "dt") {
inline("**")
return
}
if (name === "dd") return block()
if (
name === "strong" ||
name === "b" ||
name === "em" ||
name === "i" ||
name === "s" ||
name === "strike" ||
name === "del"
) {
const value = name === "strong" || name === "b" ? "**" : name === "em" || name === "i" ? "*" : "~~"
const trailingSpace = pendingSpace
pendingSpace = false
if (frame.marker) activeMarker = frame.marker.previous
if (frame.marker && (frame.marker.block !== blockCount || output.length === frame.marker.index + 1)) {
output[frame.marker.index] = ""
pendingSpace = trailingSpace || frame.marker.leadingSpace === true
return
}
inline(value)
pendingSpace = trailingSpace || frame.marker?.leadingSpace === true
return
}
if (name === "a") {
if (frame.link && (activeLink === frame.link || !activeLink)) {
activeLink = frame.link
if (linkOpen) append(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
else if (last && last !== "\n") append(`](${destination(frame.link?.href ?? "")}${title(frame.link?.title)})`)
linkOpen = false
activeLink = undefined
}
return
}
if (/^h[1-6]$/.test(name) || blocks.has(name)) {
block()
if (frame.suspendedLink) activeLink = frame.suspendedLink
return
}
if (name === "blockquote") {
quoteDepth--
block()
if (frame.suspendedLink) activeLink = frame.suspendedLink
return
}
if (name === "li") {
activeItem = frame.item?.previous
return block()
}
if (name === "ul" || name === "ol") {
activeList = frame.list?.previous
block()
if (frame.suspendedLink) activeLink = frame.suspendedLink
return
}
if ((name === "th" || name === "td") && tableDepth === 1) {
activeCell = undefined
if (frame.cell) {
const value = take(frame.cell.start)
.replace(/[\t\r\n ]+/g, " ")
.trim()
.replace(/(?<!\\)\|/g, "\\|")
activeTable?.row?.push(value)
}
return
}
if (name === "tr") {
if (tableDepth !== 1) return
if (activeTable?.row) activeTable.rows.push(activeTable.row)
if (activeTable) activeTable.row = undefined
pendingSpace = true
return
}
if (name === "caption" && frame.caption && activeTable) {
activeTable.caption = take(frame.caption.start)
.replace(/[\t\r\n ]+/g, " ")
.trim()
return
}
if (name === "table") {
tableDepth--
if (tableDepth === 0) {
const table = frame.table
activeTable = table?.previous
if (table) {
const loose = take(table.start)
.replace(/[\t\r\n ]+/g, " ")
.trim()
const width = table.rows[0]?.length ?? 0
const rectangular = width > 0 && table.rows.every((row) => row.length === width)
if (loose) {
append(loose)
block()
}
if (table.caption) {
append(table.caption)
block()
}
if (!table.fallback && rectangular) {
const prefix = `${quoteDepth > 0 ? `${"> ".repeat(Math.min(8, quoteDepth))}` : ""}${pendingIndent}`
pendingIndent = ""
append(`${prefix}| ${table.rows[0].join(" | ")} |\n${prefix}|${" --- |".repeat(width)}`)
for (const row of table.rows.slice(1)) append(`\n${prefix}| ${row.join(" | ")} |`)
} else {
for (const [index, row] of table.rows.entries()) {
if (index > 0) block()
append(row.join(" | "))
}
}
}
block()
if (frame.suspendedLink) activeLink = frame.suspendedLink
return
}
pendingSpace = true
}
},
})
for (let index = 0; index < html.length; index += 64 * 1024) parser.write(html.slice(index, index + 64 * 1024))
parser.end()
const normalized: string[] = []
let pendingText = ""
const flushText = () => {
if (!pendingText) return
const lines = pendingText
.replace(/[ \t]+\n/g, (space) => (space.startsWith(" ") ? " \n" : "\n"))
.replace(/\n{3,}/g, "\n\n")
.split("\n")
normalized.push(
lines
.map((line, index) => {
if (line) return line
const before = lines[index - 1]?.match(/^(?:> )+/)?.[0]
const after = lines[index + 1]?.match(/^(?:> )+/)?.[0]
if (!before || !after || before.length === after.length) return line
return "> ".repeat(Math.min(before.length, after.length) / 2).trimEnd()
})
.join("\n"),
)
pendingText = ""
}
for (const chunk of output) {
if (typeof chunk !== "string") {
flushText()
normalized.push(chunk.raw)
continue
}
pendingText += chunk
}
flushText()
return sliceBytes(normalized.join("").trim(), MAX_MARKDOWN_BYTES)
}
+3 -13
View File
@@ -5,12 +5,12 @@ import { ToolFailure } from "@opencode-ai/ai"
import { Duration, Effect, Schema } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Parser } from "htmlparser2"
import TurndownService from "turndown"
import { Permission } from "../../permission.js"
import { convertHTMLToMarkdown, MAX_MARKDOWN_BYTES } from "../html-markdown.js"
import { collectBoundedResponseBody } from "../http-body.js"
export const name = "webfetch"
export const MAX_RESPONSE_BYTES = 5 * 1024 * 1024
export const MAX_RESPONSE_BYTES = MAX_MARKDOWN_BYTES
export const DEFAULT_TIMEOUT_SECONDS = 30
export const MAX_TIMEOUT_SECONDS = 120
@@ -196,14 +196,4 @@ export function extractTextFromHTML(html: string) {
return text.trim()
}
export function convertHTMLToMarkdown(html: string) {
const turndown = new TurndownService({
headingStyle: "atx",
hr: "---",
bulletListMarker: "-",
codeBlockStyle: "fenced",
emDelimiter: "*",
})
turndown.remove(["script", "style", "meta", "link"])
return turndown.turndown(html)
}
export { convertHTMLToMarkdown }
-353
View File
@@ -1,353 +0,0 @@
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Schema } from "effect"
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "./git.js"
import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { ProjectSchema } from "./project/schema.js"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Location } from "./location.js"
import { Worktree } from "@opencode-ai/schema/worktree"
import { WorktreeTable } from "./worktree/sql.js"
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Worktree.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshInput = Schema.Struct({
projectID: ProjectSchema.ID,
}).annotate({ identifier: "Worktree.RefreshInput" })
export type RefreshInput = typeof RefreshInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "Worktree.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
export const ListInput = Worktree.ListInput
export type ListInput = typeof ListInput.Type
export const List = Worktree.List
export type List = typeof List.Type
export const ListEntry = Schema.Struct({
directory: AbsolutePath,
type: Schema.Literals(["root", "worktree"]),
}).annotate({ identifier: "Worktree.ListEntry" })
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
"Worktree.SourceDirectoryNotFoundError",
{ projectID: ProjectSchema.ID, directory: Schema.optional(AbsolutePath) },
) {}
export class DestinationExistsError extends Schema.TaggedErrorClass<DestinationExistsError>()(
"Worktree.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
"Worktree.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
"Worktree.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
"Worktree.DuplicateStrategyError",
{ strategy: StrategyID },
) {}
export type Error =
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Git.WorktreeError
export interface Strategy {
readonly id: StrategyID
readonly create: (input: {
sourceDirectory: AbsolutePath
directory: AbsolutePath
}) => Effect.Effect<Info, Git.WorktreeError | DirectoryUnavailableError>
readonly remove: (input: {
directory: AbsolutePath
force: boolean
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
}
export const Event = Worktree.Event
interface StoredInput {
readonly projectID: ProjectSchema.ID
readonly directory: AbsolutePath
readonly strategy?: string
}
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Interface {
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<List>
readonly create: (input: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
export const refreshAfterBoot = Effect.gen(function* () {
const location = yield* Location.Service
const worktrees = yield* Service
yield* Effect.gen(function* () {
yield* Effect.logInfo("worktree refresh started", { projectID: location.project.id })
const result = yield* worktrees.refresh({ projectID: location.project.id })
yield* Effect.logInfo("worktree refresh done", {
projectID: location.project.id,
updated: result.updated,
removed: result.removed,
})
}).pipe(
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
Effect.asVoid,
)
})
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, projectID))
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
primary: Effect.fnUntraced(function* (projectID: ProjectSchema.ID) {
return yield* db
.select({ directory: ProjectTable.worktree })
.from(ProjectTable)
.where(eq(ProjectTable.id, projectID))
.get()
.pipe(Effect.orDie)
}),
create: Effect.fnUntraced(function* (input: StoredInput, tx?: Transaction) {
return (
(yield* (tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: { strategy: input.strategy ?? null },
setWhere: input.strategy
? or(isNull(WorktreeTable.strategy), ne(WorktreeTable.strategy, input.strategy))
: isNotNull(WorktreeTable.strategy),
})
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
remove: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) {
return (
(yield* (tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(Effect.orDie)) !== undefined
)
}),
}
const registry = new Map<StrategyID, Strategy>()
const register = Effect.fn("Worktree.register")(function* (strategy: Strategy) {
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
registry.set(strategy.id, strategy)
})
// Register default strategies
const gitStrategy = yield* WorktreeGit.make
yield* register(gitStrategy).pipe(Effect.orDie)
const strategies = () => Array.from(registry.values())
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined, projectID: ProjectSchema.ID) {
const sourceDirectory = input ?? (yield* ops.primary(projectID))?.directory
if (!sourceDirectory) return yield* new SourceDirectoryNotFoundError({ projectID })
const resolved = yield* canonical(fs, sourceDirectory)
if ((yield* ops.find(projectID, resolved)) === undefined)
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
return resolved
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput) {
const selected = yield* getStrategy(input.strategy)
const sourceDirectory = yield* source(input.from, input.projectID)
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
})
yield* changed(
input.projectID,
yield* ops.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
}),
)
return result
})
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
directory: worktreeDirectory,
force: input.force,
})
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
})
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
const stored = yield* ops.list(input.projectID)
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const sourceDirectories = checked
.filter((item) => item.strategy === undefined && item.exists)
.map((item) => item.directory)
const discovered = yield* Effect.forEach(
sourceDirectories,
(sourceDirectory) =>
Effect.forEach(strategies(), (strategy) =>
strategy.list(sourceDirectory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
Effect.map((items) =>
items.map((item) => ({
directory: item.directory,
strategy: item.type === "worktree" ? strategy.id : undefined,
})),
),
),
),
{ concurrency: "unbounded" },
).pipe(
Effect.map((sets) => new Map(sets.flat(2).map((item) => [item.directory, item] as const)).values().toArray()),
)
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const result = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.forEach(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
},
tx,
),
),
removed: Effect.forEach(removed, (directory) => ops.remove(input.projectID, directory, tx)),
}),
)
.pipe(Effect.orDie)
const changes = {
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
removed: removed.filter((_, index) => result.removed[index]),
}
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
register,
list: ops.list,
create,
remove,
refresh,
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node],
})
export const refreshNode = makeLocationNode({
name: "worktree-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
-16
View File
@@ -1,16 +0,0 @@
export * as WorktreeDirectory from "./directory.js"
import { Effect, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AbsolutePath } from "../schema.js"
export class DirectoryUnavailableError extends Schema.TaggedErrorClass<DirectoryUnavailableError>()(
"Worktree.DirectoryUnavailableError",
{ directory: AbsolutePath },
) {}
export const canonical = Effect.fnUntraced(function* (fs: FSUtil.Interface, input: AbsolutePath) {
const resolved = AbsolutePath.make(yield* fs.resolve(input))
if (!(yield* fs.isDir(resolved))) return yield* new DirectoryUnavailableError({ directory: input })
return resolved
})
-39
View File
@@ -1,39 +0,0 @@
export * as WorktreeGit from "./git.js"
import { Effect } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git.js"
import { canonical, DirectoryUnavailableError } from "./directory.js"
import type { ListEntry, Strategy } from "../worktree.js"
export const make = Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
return {
id: Worktree.StrategyID.make("git"),
create: Effect.fn("Worktree.Git.create")(function* (input) {
const repository = yield* git.repo.discover(input.sourceDirectory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.sourceDirectory })
yield* git.worktree.create({ repository, directory: input.directory })
return { directory: yield* canonical(fs, input.directory) }
}),
remove: Effect.fn("Worktree.Git.remove")(function* (input) {
const repository = yield* git.repo.discover(input.directory)
if (!repository) return yield* new DirectoryUnavailableError({ directory: input.directory })
yield* git.worktree.remove({ repository, directory: input.directory, force: input.force })
}),
list: Effect.fn("Worktree.Git.list")(function* (directory) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new DirectoryUnavailableError({ directory })
const entries = yield* git.worktree.list(repository)
return yield* Effect.forEach(entries, (entry) =>
canonical(fs, entry.directory).pipe(
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed(undefined)),
),
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
}),
} satisfies Strategy
})
-20
View File
@@ -1,20 +0,0 @@
import { integer, primaryKey, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { absoluteColumn } from "../database/path.js"
import { ProjectSchema } from "../project/schema.js"
import { ProjectTable } from "../project/sql.js"
export const WorktreeTable = sqliteTable(
"worktree",
{
project_id: text()
.$type<ProjectSchema.ID>()
.notNull()
.references(() => ProjectTable.id, { onDelete: "cascade" }),
directory: absoluteColumn().notNull(),
strategy: text(),
time_created: integer()
.notNull()
.$default(() => Date.now()),
},
(table) => [primaryKey({ columns: [table.project_id, table.directory] })],
)
@@ -12,7 +12,6 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -128,31 +127,6 @@ describe("DatabaseMigration", () => {
)
})
test("copies project directories into worktrees without removing the old table", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`)
yield* db.run(
sql`CREATE TABLE project_directory (project_id text NOT NULL, directory text NOT NULL, type text, strategy text, time_created integer NOT NULL, PRIMARY KEY (project_id, directory))`,
)
yield* db.run(
sql`INSERT INTO project_directory (project_id, directory, type, strategy, time_created) VALUES ('project', '/root', 'main', NULL, 1), ('project', '/legacy', 'git_worktree', NULL, 2), ('project', '/strategy', NULL, 'git_worktree', 3), ('project', '/custom', NULL, 'acme/snapshot', 4)`,
)
yield* DatabaseMigration.applyOnly(db, [worktreeMigration])
expect(yield* db.all(sql`SELECT directory, strategy FROM worktree ORDER BY directory`)).toEqual([
{ directory: "/custom", strategy: "acme/snapshot" },
{ directory: "/legacy", strategy: "git" },
{ directory: "/root", strategy: null },
{ directory: "/strategy", strategy: "git" },
])
expect(yield* db.get(sql`SELECT count(*) AS count FROM project_directory`)).toEqual({ count: 4 })
}),
)
})
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "auth.json")
@@ -78,6 +78,7 @@ describe("node build", () => {
acquisitions++
return Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
})
}),
+1
View File
@@ -6,5 +6,6 @@ export const globalProjectLayer = Layer.succeed(
Project.Service.of({
list: () => Effect.succeed([]),
resolve: (directory) => Effect.succeed({ id: Project.ID.global, directory, canonical: directory }),
directories: () => Effect.succeed([]),
}),
)
+1
View File
@@ -13,6 +13,7 @@ const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
list: () => Effect.succeed([]),
directories: () => Effect.succeed([]),
resolve: () =>
Effect.succeed({
id: Project.ID.make("project"),
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { and, eq, isNull } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { Effect, Fiber, Stream } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
@@ -11,23 +11,21 @@ import { Git } from "@opencode-ai/core/git"
import { Database } from "@opencode-ai/core/database/database"
import { Bus } from "@opencode-ai/core/bus"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { Worktree } from "@opencode-ai/core/worktree"
import { WorktreeDirectory } from "@opencode-ai/core/worktree/directory"
import { WorktreeTable } from "@opencode-ai/core/worktree/sql"
import { ProjectDirectoryTable, ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectCopy } from "@opencode-ai/core/project/copy"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Worktree.node, Database.node, Bus.node])))
const projectIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Project.node, Worktree.node, Database.node, Bus.node])),
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, Bus.node, ProjectDirectories.node])),
)
function abs(input: string) {
return AbsolutePath.make(input)
}
const gitWorktree = Worktree.StrategyID.make("git")
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
@@ -46,7 +44,7 @@ function setup() {
)
yield* Effect.promise(() => initRepo(root.path))
const sourceDirectory = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const projectID = Project.ID.make("worktree-project")
const projectID = Project.ID.make("copy-project")
const { db } = yield* Database.Service
yield* db
.insert(ProjectTable)
@@ -54,7 +52,7 @@ function setup() {
.run()
.pipe(Effect.orDie)
yield* db
.insert(WorktreeTable)
.insert(ProjectDirectoryTable)
.values({ project_id: projectID, directory: sourceDirectory })
.run()
.pipe(Effect.orDie)
@@ -65,9 +63,9 @@ function setup() {
function stored(projectID: Project.ID) {
return Database.Service.use(({ db }) =>
db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, projectID))
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
.from(ProjectDirectoryTable)
.where(eq(ProjectDirectoryTable.project_id, projectID))
.all()
.pipe(
Effect.orDie,
@@ -76,201 +74,92 @@ function stored(projectID: Project.ID) {
)
}
describe("Worktree", () => {
projectIt.live("tracks canonical and linked worktrees when a project resolves", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const linked = `${root.path}-linked`
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(linked, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add ${linked} -b linked-${Date.now()}`.cwd(root.path).quiet())
const project = yield* Project.Service
const resolved = yield* project.resolve(abs(linked))
expect(yield* stored(resolved.id)).toEqual(
[
{ directory: abs(yield* Effect.promise(() => fs.realpath(root.path))), strategy: null },
{ directory: abs(yield* Effect.promise(() => fs.realpath(linked))), strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
)
describe("ProjectCopy", () => {
it.effect("accepts arbitrary non-empty strategy ids", () =>
Effect.sync(() => {
expect(String(Worktree.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
expect(() => Worktree.StrategyID.make(" acme/snapshot ")).toThrow()
expect(() => Worktree.StrategyID.make(" ")).toThrow()
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
}),
)
it.effect("reports unavailable strategy ids", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const unavailable = Worktree.StrategyID.make("acme/missing")
const error = yield* worktree
const copy = yield* ProjectCopy.Service
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
const error = yield* copy
.create({
projectID: input.projectID,
strategy: unavailable,
from: input.sourceDirectory,
sourceDirectory: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-strategy`),
name: "worktree",
name: "copy",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
}),
)
it.live("requires a tracked project worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
yield* input.db
.delete(WorktreeTable)
.where(eq(WorktreeTable.project_id, input.projectID))
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: abs(`${input.root.path}-missing-source`),
name: "worktree",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.SourceDirectoryNotFoundError)
if (error instanceof Worktree.SourceDirectoryNotFoundError) expect(error.directory).toBe(input.sourceDirectory)
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
}),
)
it.live("creates and removes a git worktree directory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
const bus = yield* Bus.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
const target = abs(path.join(parent, "worktree"))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-created"))
const target = abs(path.join(parent, "copy"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const fiber = yield* bus
.subscribe(Worktree.Event.Updated)
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const created = yield* worktree.create({
const created = yield* copy.create({
projectID: input.projectID,
strategy: gitWorktree,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "worktree",
name: "copy",
})
expect(created.directory).toBe(target)
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: created.directory, strategy: "git" },
{ directory: created.directory, strategy: "git_worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
}),
)
it.live("rejects a missing source directory", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const error = yield* worktree
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: abs(path.join(temp, "does-not-exist")),
directory: abs(`${input.root.path}-missing-directory`),
name: "worktree",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(WorktreeDirectory.DirectoryUnavailableError)
}),
)
it.live("creates from another managed worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
yield* Effect.addFinalizer(() =>
Effect.all([
Effect.promise(() => fs.rm(sourceParent, { recursive: true, force: true })).pipe(Effect.ignore),
Effect.promise(() => fs.rm(targetParent, { recursive: true, force: true })).pipe(Effect.ignore),
]).pipe(Effect.asVoid),
)
const source = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
directory: sourceParent,
name: "source",
})
yield* input.db
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, input.projectID), isNull(WorktreeTable.strategy)))
.run()
.pipe(Effect.orDie)
const created = yield* worktree.create({
projectID: input.projectID,
strategy: gitWorktree,
from: source.directory,
directory: targetParent,
name: "target",
})
expect(created.directory).toBe(abs(path.join(targetParent, "target")))
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* worktree.remove({ projectID: input.projectID, directory: source.directory, force: false })
}),
)
it.live("requires force to remove a dirty git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-dirty"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
const created = yield* worktree.create({
const created = yield* copy.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "worktree",
name: "copy",
})
yield* Effect.promise(() => Bun.write(path.join(created.directory, "dirty.txt"), "dirty"))
const error = yield* worktree
const error = yield* copy
.remove({ projectID: input.projectID, directory: created.directory, force: false })
.pipe(Effect.flip)
@@ -279,115 +168,115 @@ describe("Worktree", () => {
expect(error.operation).toBe("remove")
expect(error.forceRequired).toBe(true)
}
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git" })
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: true })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
expect(yield* Effect.promise(() => Bun.file(created.directory).exists())).toBe(false)
}),
)
it.live("preserves worktrees whose stored strategy is unavailable", () =>
it.live("preserves copies whose stored strategy is unavailable", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const unavailable = abs(`${input.root.path}-worktree-unavailable`)
const copy = yield* ProjectCopy.Service
const unavailable = abs(`${input.root.path}-copy-unavailable`)
yield* Effect.promise(() => fs.mkdir(unavailable))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
yield* input.db
.insert(WorktreeTable)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
.run()
.pipe(Effect.orDie)
const error = yield* worktree
const error = yield* copy
.remove({ projectID: input.projectID, directory: unavailable, force: false })
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
}),
)
it.live("adds a numeric suffix when a worktree directory already exists", () =>
it.live("adds a numeric suffix when a copy directory already exists", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
const target = abs(path.join(parent, "worktree-3"))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-suffix"))
const target = abs(path.join(parent, "copy-3"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy"), { recursive: true }))
yield* Effect.promise(() => fs.mkdir(path.join(parent, "copy-2")))
const created = yield* worktree.create({
const created = yield* copy.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "worktree",
name: "copy",
})
expect(created.directory).toBe(target)
expect(
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree")).then((item) => item.isDirectory())),
).toBe(true)
expect(
yield* Effect.promise(() => fs.stat(path.join(parent, "worktree-2")).then((item) => item.isDirectory())),
).toBe(true)
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy")).then((item) => item.isDirectory()))).toBe(
true,
)
expect(yield* Effect.promise(() => fs.stat(path.join(parent, "copy-2")).then((item) => item.isDirectory()))).toBe(
true,
)
yield* worktree.remove({ projectID: input.projectID, directory: created.directory, force: false })
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: false })
}),
)
it.live("fails after ten worktree directory conflicts", () =>
it.live("fails after ten copy directory conflicts", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
const parent = abs(path.join(temp, path.basename(input.root.path) + "-copy-conflicts"))
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(parent, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() =>
Promise.all(
Array.from({ length: 10 }, (_, index) =>
fs.mkdir(path.join(parent, index === 0 ? "worktree" : `worktree-${index + 1}`), { recursive: true }),
fs.mkdir(path.join(parent, index === 0 ? "copy" : `copy-${index + 1}`), { recursive: true }),
),
),
)
const error = yield* worktree
const error = yield* copy
.create({
projectID: input.projectID,
strategy: gitWorktree,
from: input.sourceDirectory,
sourceDirectory: input.sourceDirectory,
directory: parent,
name: "worktree",
name: "copy",
})
.pipe(Effect.flip)
expect(error).toBeInstanceOf(Worktree.DestinationExistsError)
if (error instanceof Worktree.DestinationExistsError)
expect(error.directory).toBe(abs(path.join(parent, "worktree-10")))
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
if (error instanceof ProjectCopy.DestinationExistsError)
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
}),
)
it.live("does not publish an event when refresh finds no directory changes", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
const bus = yield* Bus.Service
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
const event = yield* bus.subscribe(ProjectCopy.Event.Updated).pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped,
Effect.flatMap((fiber) =>
Effect.gen(function* () {
yield* Effect.yieldNow
yield* worktree.refresh({ projectID: input.projectID })
yield* copy.refresh({ projectID: input.projectID })
return yield* Fiber.join(fiber).pipe(Effect.timeoutOption("50 millis"))
}),
),
@@ -400,36 +289,36 @@ describe("Worktree", () => {
it.live("refresh discovers and prunes an externally managed git worktree", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
const bus = yield* Bus.Service
const target = abs(`${input.root.path}-worktree-external`)
const target = abs(`${input.root.path}-copy-external`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* input.db
.insert(WorktreeTable)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: target })
.run()
.pipe(Effect.orDie)
const fiber = yield* bus
.subscribe(Worktree.Event.Updated)
.subscribe(ProjectCopy.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git" },
{ directory: discovered, strategy: "git_worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
expect(Array.from(yield* Fiber.join(fiber))[0]?.data).toEqual({ projectID: input.projectID })
yield* Effect.promise(() => $`git worktree remove --force ${target}`.cwd(input.root.path).quiet())
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
)
@@ -437,9 +326,9 @@ describe("Worktree", () => {
it.live("refresh ignores stale git worktree registrations", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const stale = abs(`${input.root.path}-worktree-stale`)
const target = abs(`${input.root.path}-worktree-after-stale`)
const copy = yield* ProjectCopy.Service
const stale = abs(`${input.root.path}-copy-stale`)
const target = abs(`${input.root.path}-copy-after-stale`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
@@ -447,13 +336,13 @@ describe("Worktree", () => {
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* worktree.refresh({ projectID: input.projectID })
yield* copy.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git" },
{ directory: discovered, strategy: "git_worktree" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
@@ -463,9 +352,9 @@ describe("Worktree", () => {
Effect.gen(function* () {
const input = yield* setup()
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
yield* worktree.refresh({ projectID: input.projectID })
yield* copy.refresh({ projectID: input.projectID })
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
}),
@@ -473,9 +362,9 @@ describe("Worktree", () => {
it.live("refresh with no roots is a no-op", () =>
Effect.gen(function* () {
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
expect(yield* worktree.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
updated: [],
removed: [],
})
@@ -487,13 +376,13 @@ describe("Worktree", () => {
const input = yield* setup()
const missing = abs(`${input.root.path}-missing-checkout`)
yield* input.db
.insert(WorktreeTable)
.insert(ProjectDirectoryTable)
.values({ project_id: input.projectID, directory: missing })
.run()
.pipe(Effect.orDie)
const worktree = yield* Worktree.Service
const copy = yield* ProjectCopy.Service
expect(yield* worktree.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
}),
@@ -0,0 +1,69 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Database } from "@opencode-ai/core/database/database"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Database.node, ProjectDirectories.node])))
const projectID = Project.ID.make("project-directories")
const directory = AbsolutePath.make("/tmp/project-directories")
function setup() {
return Database.Service.use(({ db }) =>
db
.insert(ProjectTable)
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
.onConflictDoNothing()
.run()
.pipe(Effect.orDie),
)
}
describe("ProjectDirectories", () => {
it.effect("decodes directory schemas", () =>
Effect.sync(() => {
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
}),
)
it.effect("creates once and ignores conflicts", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
expect(yield* service.create({ projectID, directory })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
}),
)
it.effect("returns an empty list for missing projects", () =>
Effect.gen(function* () {
const service = yield* ProjectDirectories.Service
expect(yield* service.list(Project.ID.make("missing-project"))).toEqual([])
}),
)
it.effect("replaces the strategy when requested", () =>
Effect.gen(function* () {
yield* setup()
const service = yield* ProjectDirectories.Service
yield* service.create({ projectID, directory, strategy: "old/strategy" })
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
expect(yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" })).toBe(true)
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
}),
)
})
+3
View File
@@ -364,6 +364,9 @@ describe("Project.resolve", () => {
sandboxes: [abs("/preserved-sandbox")],
time: { created: 1, initialized: 2 },
})
expect(
(yield* project.directories({ projectID: id })).toSorted((a, b) => a.directory.localeCompare(b.directory)),
).toEqual([{ directory: yield* real(tmp.path) }, { directory: yield* real(worktree), strategy: "git_worktree" }])
}),
)
})
+1 -1
View File
@@ -137,7 +137,7 @@ describe("Session.create", () => {
.where(eq(EventTable.aggregate_id, project.id))
.all()
.pipe(Effect.orDie)
expect(announced.map((event) => event.type)).toEqual(["worktree.resolved.1"])
expect(announced.map((event) => event.type)).toEqual(["project.directory.resolved.1"])
}),
),
)
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import path from "path"
import { Effect } from "effect"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Event } from "@opencode-ai/schema/project-directories"
import { Bus } from "@opencode-ai/core/bus"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
@@ -92,7 +92,7 @@ describe("Session.move", () => {
projectID: Project.ID.global,
})
// The former directory becomes a project after the session left it.
yield* bus.publish(Worktree.Event.Resolved, {
yield* bus.publish(Event.Resolved, {
projectID: Project.ID.make("adopting"),
directory: previous,
previous: Project.ID.global,
+7 -2
View File
@@ -1204,12 +1204,17 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("interrupts a source Location runner after a Session moves", () =>
it.effect("preserves instruction state and interrupts the source Location runner after a Session moves", () =>
Effect.gen(function* () {
const session = yield* setup
const bus = yield* Bus.Service
const { db } = yield* Database.Service
yield* runPrompt(session, "First")
const instructionState = yield* db
.select()
.from(InstructionStateTable)
.where(eq(InstructionStateTable.session_id, sessionID))
.get()
yield* bus.publish(SessionEvent.Moved, {
sessionID,
@@ -1218,7 +1223,7 @@ describe("SessionRunnerLLM", () => {
})
expect(
yield* db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
).toBeUndefined()
).toEqual(instructionState)
yield* admit(session, "Second")
const exit = yield* session.resume(sessionID).pipe(Effect.exit)
+7 -9
View File
@@ -6,7 +6,7 @@ import { Location } from "@opencode-ai/schema/location"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { ProjectDirectories } from "@opencode-ai/schema/project-directories"
import { PermissionV1 } from "@opencode-ai/schema/permission-v1"
import { Prompt } from "@opencode-ai/schema/prompt"
import { SessionInbox } from "@opencode-ai/schema/session-inbox"
@@ -39,7 +39,7 @@ test("Core reuses the canonical shared schemas", async () => {
coreModel,
corePermission,
corePermissionV1,
coreWorktree,
coreProjectCopy,
corePty,
coreProject,
coreProvider,
@@ -60,7 +60,7 @@ test("Core reuses the canonical shared schemas", async () => {
import("@opencode-ai/core/model"),
import("@opencode-ai/core/permission"),
import("@opencode-ai/core/v1/permission"),
import("@opencode-ai/core/worktree"),
import("@opencode-ai/core/project/copy"),
import("@opencode-ai/core/pty"),
import("@opencode-ai/core/project/schema"),
import("@opencode-ai/core/provider"),
@@ -112,16 +112,14 @@ test("Core reuses the canonical shared schemas", async () => {
[corePermission.Rule, Permission.Rule],
[corePermission.Ruleset, Permission.Ruleset],
[corePermissionV1.Event, PermissionV1.Event],
[coreWorktree.CreateInput, Worktree.CreateInput],
[coreWorktree.RemoveInput, Worktree.RemoveInput],
[coreWorktree.Info, Worktree.Info],
[coreWorktree.ListInput, Worktree.ListInput],
[coreWorktree.List, Worktree.List],
[coreWorktree.Event, Worktree.Event],
[coreProjectCopy.Event, ProjectDirectories.Event],
[corePty.Info, Pty.Info],
[corePty.Event, Pty.Event],
[coreProject.ID, Project.ID],
[coreProject.Current, Project.Current],
[coreProject.Directory, Project.Directory],
[coreProject.DirectoriesInput, Project.DirectoriesInput],
[coreProject.Directories, Project.Directories],
[coreReference.LocalSource, Reference.LocalSource],
[coreReference.GitSource, Reference.GitSource],
[coreReference.Source, Reference.Source],
+313 -6
View File
@@ -68,9 +68,316 @@ describe("WebFetchTool helpers", () => {
})
test("ports HTML text and markdown conversions without active content", () => {
const html = "<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide**")
const html =
"<h1>Hello</h1><script>bad()</script><p>world <strong>wide</strong> <product-name>today</product-name></p><style>.bad {}</style>"
expect(WebFetchTool.extractTextFromHTML(html)).toBe("Helloworld wide today")
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("# Hello\n\nworld **wide** today")
})
test("renders headings, inline semantics, links, images, breaks, and thematic breaks", () => {
const html = `<h2>Read <em>this</em></h2><p><a href="https://example.com/a (b)" title="Example">docs</a><br><img src="diagram.png" alt="a ] b"></p><hr><p><del>old</del></p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`## Read *this*\n\n[docs](https://example.com/a%20\\(b\\) "Example") \n![a \\] b](diagram.png)\n\n---\n\n~~old~~`,
)
})
test("preserves inline and preformatted code verbatim with safe fences", () => {
const html = `<p>Use <code>say(\`hello\`)</code> now.</p><pre><code class="language-ts">const fence = \`\`\`\n&amp; stays decoded</code></pre>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`Use \`\`say(\`hello\`)\`\` now.\n\n~~~ts\nconst fence = \`\`\`\n& stays decoded\n~~~`,
)
})
test("keeps nested ordered and unordered lists structurally readable", () => {
const html = `<ol start="3"><li>alpha<ul><li>nested <strong>item</strong></li></ul></li><li><p>beta first</p><p>beta second</p></li></ol>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`3. alpha\n\n - nested **item**\n\n4. beta first\n\n beta second`,
)
})
test("renders blockquotes and tables as readable Markdown", () => {
const html = `<blockquote><p>quoted <em>text</em></p><ul><li>point</li></ul></blockquote><table><thead><tr><th>Name</th><th>Value</th></tr></thead><tbody><tr><td>one</td><td><code>1</code></td></tr></tbody></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`> quoted *text*\n\n> - point\n\n| Name | Value |\n| --- | --- |\n| one | \`1\` |`,
)
})
test("decodes entities and normalizes prose whitespace without joining words", () => {
const html = `<p>alpha\n <span>&amp; beta</span> <unknown>caf&eacute;</unknown>&nbsp;gamma 😀</p><p>delta</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`alpha & beta café gamma 😀\n\ndelta`)
})
test("omits active and fallback content while retaining surrounding prose", () => {
const html = `<p>before <script><b>bad</b></script><style>bad</style><noscript>bad</noscript><iframe>bad</iframe><object>bad</object><embed src="bad"><meta content="bad"><link href="bad"><template>bad</template> after</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("before after")
})
test("is deterministic and bounded for malformed maximum-size input", () => {
const html = `<main><p>${"visible &amp; text ".repeat(250_000)}</main></p></unknown>`
const first = WebFetchTool.convertHTMLToMarkdown(html)
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(first)
expect(first.startsWith("visible & text visible & text")).toBe(true)
expect(first.length).toBeLessThanOrEqual(html.length)
})
test("bounds deeply nested list output and fragmented code fences", () => {
const lists = `${"<ul><li>item".repeat(2_000)}${"</li></ul>".repeat(2_000)}`
const quotes = `${"<blockquote><p>item".repeat(2_000)}${"</p></blockquote>".repeat(2_000)}`
const code = `<pre>${"` x ".repeat(250_000)}</pre>`
expect(WebFetchTool.convertHTMLToMarkdown(lists).length).toBeLessThan(lists.length * 4)
expect(WebFetchTool.convertHTMLToMarkdown(quotes).length).toBeLessThan(quotes.length * 4)
expect(() => WebFetchTool.convertHTMLToMarkdown(code)).not.toThrow()
expect(
WebFetchTool.convertHTMLToMarkdown(
"<div>".repeat(20_000) + "safe<script><b>bad</b>&amp;</script><p>tail &amp;</p>",
),
).toBe("safe tail &")
})
test("escapes prose that would otherwise become Markdown structure", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p># heading</p><p>1. item</p><p>---</p><p>a | b</p>`)).toBe(
`\\# heading\n\n1\\. item\n\n\\---\n\na \\| b`,
)
})
test("preserves code whitespace and quotes every line of multiline blocks", () => {
const html = `<blockquote><pre>line \n\n\nnext</pre><table><tr><td>a|b</td><td>c</td></tr></table></blockquote>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`> \`\`\`\n> line \n> \n> \n> next\n> \`\`\`\n\n> | a\\|b | c |\n> | --- | --- |`,
)
})
test("keeps nested blockquotes inside their outer quote", () => {
const html = `<blockquote><p>outer</p><blockquote><p>inner</p></blockquote><p>end</p></blockquote>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`> outer\n>\n> > inner\n>\n> end`)
})
test("keeps visible whitespace around inline emphasis", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong> b</strong> c a <em>b </em>c</p>`)).toBe(`a **b** c a *b* c`)
expect(WebFetchTool.convertHTMLToMarkdown(`a<strong> </strong>b a<em> </em>b`)).toBe(`a b a b`)
})
test("captures formatting elements inside preformatted content as code only", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<pre><b>x</b><i>y</i><del>z</del></pre>`)).toBe(`\`\`\`\nxyz\n\`\`\``)
})
test("normalizes multiline table cells without changing their columns", () => {
const html = `<table><tr><td>x<br>y</td><td><code>a|b</code></td><td><p>first</p><p>second</p></td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| x y | \`a\\|b\` | first second |\n| --- | --- | --- |`)
})
test("flattens nested tables without corrupting the outer table", () => {
const html = `<table><tr><th>Parent</th><th>Sibling</th></tr><tr><td>Before<table><tr><th>Key</th><th>Value</th></tr><tr><td>A</td><td>1</td></tr></table>After</td><td>Tail</td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`| Parent | Sibling |\n| --- | --- |\n| Before Key Value A 1 After | Tail |`,
)
})
test("preserves loose text around malformed table rows", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<table>before<tr><td>cell</td></tr>after</table>`)).toBe(
`before after\n\n| cell |\n| --- |`,
)
expect(WebFetchTool.convertHTMLToMarkdown(`<table>alpha</table>`)).toBe(`alpha`)
})
test("escapes tilde fences and removes empty emphasis markers", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>~~~</p><p><strong></strong>content</p><p>~~~</p>`)).toBe(
`\\~\\~\\~\n\ncontent\n\n\\~\\~\\~`,
)
})
test("parses malformed tag prefixes in linear time without a regex prepass", () => {
const small = "<a".repeat(250_000)
const large = "<a".repeat(1_000_000)
const start = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(small)
const smallDuration = Bun.nanoseconds() - start
const next = Bun.nanoseconds()
WebFetchTool.convertHTMLToMarkdown(large)
const largeDuration = Bun.nanoseconds() - next
expect(largeDuration).toBeLessThan(smallDuration * 10)
})
test("caps escaped prose and backtick-heavy pre output at the webfetch response ceiling", () => {
const prose = `<p>${"*".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</p>`
const code = `<pre>${"`".repeat(WebFetchTool.MAX_RESPONSE_BYTES - 11)}</pre>`
const proseOutput = WebFetchTool.convertHTMLToMarkdown(prose)
const codeOutput = WebFetchTool.convertHTMLToMarkdown(code)
expect(Buffer.byteLength(proseOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(Buffer.byteLength(codeOutput)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(codeOutput.startsWith("~~~\n")).toBe(true)
})
test("does not confuse source NUL text with buffered code", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>before \u00000\u0000 after</p><pre>code</pre>`)).toBe(
`before \u00000\u0000 after\n\n\`\`\`\ncode\n\`\`\``,
)
})
test("preserves multiline inline code verbatim", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p><code>first\n\n\nsecond </code></p>`)).toBe(
"` first\n\n\nsecond `",
)
})
test("prefixes inline code at the start of a blockquote line", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<blockquote><code>x</code> y</blockquote>`)).toBe(`> \`x\` y`)
})
test("keeps links nested in inline code associated with their text", () => {
const html = `<dl><dt><code>socket = new <a href="#constructor">WebSocket</a>(url)</code><dd>Creates one.</dl>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`,
)
expect(WebFetchTool.convertHTMLToMarkdown(`<code><a href="#x">x</a></code> after`)).toBe(`[\`x\`](#x) after`)
expect(
WebFetchTool.convertHTMLToMarkdown(
`<dl><dt><code><var>socket</var> = new <code><a href="#constructor">WebSocket</a></code>(<var>url</var>)</code><dd>Creates one.</dl>`,
),
).toBe(`**\` socket = new \`[\`WebSocket\`](#constructor)\`(url)\`**\n: Creates one.`)
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b<a href="/y">c</a>d</a>e</code>`)).toBe(
`\`a\`[\`b\`](\/x)[\`c\`](\/y)\`de\``,
)
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x">b</code>c`)).toBe(`\`a\`[\`b\`](\/x)c`)
expect(WebFetchTool.convertHTMLToMarkdown(`<code>a<a href="/x"><div>b</div>c</a>d</code>`)).toBe(
`\`a\`[](\/x)\n\n\`bcd\``,
)
})
test("indents nested list continuations and preserves ordered numbering", () => {
const html = `<ol start="0"><li value="4"><p>first</p><p>continued</p><ul><li><p>nested</p><p>continued nested</p></li></ul></li><li>next</li></ol>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`4. first\n\n continued\n\n - nested\n\n continued nested\n\n5. next`,
)
})
test("renders block content outside link syntax", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/docs">before<div>block</div>after</a>`)).toBe(
`[before](/docs)\n\nblock\n\n[after](/docs)`,
)
})
test("recovers nested anchors without unmatched Markdown syntax", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<a href="/a">x<a href="/b">y</a>z</a>`)).toBe(`[x](/a)[y](/b)z`)
})
test("keeps emphasis whitespace through neutral wrappers", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<p>a<strong><span> bold</span></strong>c</p>`)).toBe(`a **bold** c`)
})
test("flattens preformatted content inside table cells", () => {
const html = `<table><tr><td><pre>a|b\nnext</pre></td><td><code>x|y</code></td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`| a\\|b next | \`x\\|y\` |\n| --- | --- |`)
})
test("keeps each near-boundary inline construct closed and UTF-8-safe", () => {
const payload = "😀".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 4)
const cases = [
[`<strong>${payload}</strong>`, /^\*\*[\s\S]*\*\*$/],
[`<a href="/docs">${payload}</a>`, /^\[[\s\S]*\]\(\/docs\)$/],
[`<img src="image.png" alt="${payload}">`, /^!\[[\s\S]*\]\(image\.png\)$/],
[`<code>${payload}</code>`, /^`[\s\S]*`$/],
] as const
for (const [html, pattern] of cases) {
const output = WebFetchTool.convertHTMLToMarkdown(html)
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(output).not.toContain("")
expect(output).toMatch(pattern)
}
})
test("keeps near-boundary block constructs syntactically complete", () => {
const payload = "x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)
const table = WebFetchTool.convertHTMLToMarkdown(
`<table><tr><th>Name</th></tr><tr><td>${payload}</td></tr></table>`,
)
const list = WebFetchTool.convertHTMLToMarkdown(`<ul><li>${payload}</li></ul><ul><li>nested</li></ul>`)
const code = WebFetchTool.convertHTMLToMarkdown(`<pre>${payload}</pre>`)
for (const output of [table, list, code]) {
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect(output).not.toContain("")
}
expect(table).toMatch(/^\| Name \|\n\| --- \|\n\| [\s\S]* \|$/)
expect(list).toMatch(/^- [\s\S]*$/)
expect(list.includes("nested")).toBe(false)
expect(code.match(/^(`{3,}|~{3,})$/gm)).toHaveLength(2)
})
test("keeps quoted code within budget with a safe closed fence", () => {
const html = `<blockquote><pre>${"`".repeat(32)}${"~".repeat(32)}${"x".repeat(WebFetchTool.MAX_RESPONSE_BYTES)}</pre></blockquote>`
const output = WebFetchTool.convertHTMLToMarkdown(html)
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
const lines = output.split("\n")
expect(lines[0]).toMatch(/^> (`{33}|~{33})$/)
expect(lines.at(-1)).toBe(lines[0])
})
test("separates reconstructed tables from adjacent inline and quoted content", () => {
const html = `intro<table><tr><td>x</td></tr></table>outro<blockquote>quote<table><tr><td>cell</td></tr></table></blockquote><ul><li>item<table><tr><td>cell</td></tr></table></li></ul>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`intro\n\n| x |\n| --- |\n\noutro\n\n> quote\n\n> | cell |\n> | --- |\n\n- item\n\n| cell |\n| --- |`,
)
})
test("keeps multiline quoted code closed at the content budget", () => {
const html = `<blockquote><pre>${"x\n".repeat(WebFetchTool.MAX_RESPONSE_BYTES / 2)}</pre></blockquote><p>tail</p>`
const output = WebFetchTool.convertHTMLToMarkdown(html)
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(WebFetchTool.MAX_RESPONSE_BYTES)
expect((output.match(/(`{3}|~{3})/g) ?? []).length).toBe(2)
expect(output.includes("\uFFFD")).toBe(false)
expect(output.endsWith("tail")).toBe(true)
})
test("keeps active content suppressed when depth fallback begins", () => {
const html = `<object>${"<div>".repeat(10_001)}LEAK${"</div>".repeat(10_001)}</object><p>visible</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible")
})
test("keeps visible text after depth fallback begins inside preformatted content", () => {
const html = `<pre>${"<i>".repeat(10_001)}visible${"</i>".repeat(10_001)}</pre><p>after</p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe("visible after")
})
test("resumes links around every block structure", () => {
const html = `<a href="/x">before<blockquote><p>quote</p></blockquote><ul><li>item</li></ul><pre>code</pre><table><tr><td>cell</td></tr></table>after</a>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`[before](/x)\n\n> quote\n\n- item\n\n\`\`\`\ncode\n\`\`\`\n\n| cell |\n| --- |\n\n[after](/x)`,
)
})
test("indents child lists from the actual parent marker width", () => {
expect(WebFetchTool.convertHTMLToMarkdown(`<ol start="100"><li>outer<ul><li>inner</li></ul></li></ol>`)).toBe(
`100. outer\n\n - inner`,
)
})
test("renders captions and definition lists with readable boundaries", () => {
const html = `<table><caption>Cache modes</caption><tr><th>Name</th><th>Meaning</th></tr><tr><td>A</td><td>Local</td></tr></table><dl><dt>Cache</dt><dd>A local store</dd><dt>Origin</dt><dd>The remote source</dd></dl>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`Cache modes\n\n| Name | Meaning |\n| --- | --- |\n| A | Local |\n\n**Cache**\n: A local store\n\n**Origin**\n: The remote source`,
)
})
test("falls back to row-oriented text for table spans", () => {
const html = `<table><tr><th colspan="2">Group</th></tr><tr><td>A</td><td rowspan="2">Shared</td></tr><tr><td>B</td></tr></table>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Group\n\nA | Shared\n\nB`)
})
test("suppresses head and hidden subtrees while retaining visible body content", () => {
const html = `<head><title>noise</title></head><body><p>visible</p><div hidden>hidden</div><div aria-hidden="true">aria</div><div aria-hidden="false">shown</div></body>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`visible\n\nshown`)
})
test("preserves pre breaks and normalizes multiline link titles", () => {
const html = `<pre>first<br>second</pre><p><a href="/x" title="line one\n line two">link</a></p>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(
`\`\`\`\nfirst\nsecond\n\`\`\`\n\n[link](/x "line one line two")`,
)
})
test("renders closed and open details according to visibility", () => {
const html = `<details><summary>Closed</summary><p>secret</p></details><details open><summary>Open</summary><p>visible</p></details>`
expect(WebFetchTool.convertHTMLToMarkdown(html)).toBe(`Closed\n\nOpen\n\nvisible`)
})
})
@@ -178,7 +485,7 @@ describe("WebFetchTool registration", () => {
}),
)
it.effect("returns an error result when HTML-to-Markdown conversion throws", () =>
it.effect("converts deeply nested HTML without overflowing", () =>
Effect.gen(function* () {
reset()
respond = () =>
@@ -191,8 +498,8 @@ describe("WebFetchTool registration", () => {
const url = "https://1.1.1.1/deep-html"
expect(yield* executeTool(registry, call({ url, format: "markdown" }))).toMatchObject({
status: "error",
error: { type: "unknown" },
status: "completed",
content: [{ type: "text", text: "content" }],
})
}),
)
+4 -1
View File
@@ -10,6 +10,7 @@ import {
type Locale,
type Platform,
PlatformProvider,
preloadSessionRoute,
createDraftStore,
ServerConnection,
useCommand,
@@ -443,7 +444,9 @@ render(() => {
const api = window.api as typeof window.api & {
getWindowID?: () => Promise<string>
}
return { id: await api.getWindowID?.() }
const id = await api.getWindowID?.()
if (/^\/server\/[^/]+\/session\/[^/]+/.test(getLastActiveUrl(id ?? "browser"))) await preloadSessionRoute()
return { id }
})
return (
@@ -1,129 +0,0 @@
import { expect, test } from "vitest"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
const state = {
focused: { renderable: 1, editor: true },
elements: [],
}
test.sequential("CLI drives an externally owned OpenCode endpoint on the default port", async () => {
const root = await mkdtemp(join(tmpdir(), "opencode-drive-direct-test-"))
const requests: unknown[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 40900,
fetch(request, server) {
if (server.upgrade(request)) return
return new Response("external OpenCode simulation endpoint", {
status: 426,
})
},
websocket: {
message(socket, message) {
const request = JSON.parse(String(message)) as {
readonly id: number
readonly method: string
}
if (request.method === "simulation.handshake") {
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: request.id,
error: { code: -32601, message: "method not found" },
}),
)
return
}
requests.push(request)
socket.send(
JSON.stringify({
jsonrpc: "2.0",
id: request.id,
result: request.method === "ui.screenshot" ? "/tmp/home.png" : state,
}),
)
},
},
})
try {
const first = await sendState(root)
expect(first.status).toBe(0)
expect(JSON.parse(first.stdout)).toEqual(state)
const second = await sendState(root)
expect(second.status).toBe(0)
expect(JSON.parse(second.stdout)).toEqual(state)
const screenshot = await send(root, ["--command.ui.screenshot", '{"name":"home"}'])
expect(screenshot.status).toBe(0)
expect(screenshot.stdout.trim()).toBe("/tmp/home.png")
const ctrlTab = await send(root, ["--command.ui.press", '{"key":"tab","modifiers":{"ctrl":true}}'])
expect(ctrlTab.status).toBe(0)
const right = await send(root, ["--command.ui.press", '{"key":"right"}'])
expect(right.status).toBe(0)
const altDown = await send(root, ["--command.ui.press", '{"key":"down","modifiers":{"meta":true}}'])
expect(altDown.status).toBe(0)
const invalidAlt = await send(root, ["--command.ui.press", '{"key":"down","modifiers":{"alt":true}}'])
expect(invalidAlt.status).toBe(1)
expect(invalidAlt.stderr).toContain("alt")
expect(invalidAlt.stderr).toContain("Unexpected key with value true")
expect(requests).toEqual([
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
{ jsonrpc: "2.0", id: 1, method: "ui.state" },
{ jsonrpc: "2.0", id: 1, method: "ui.screenshot", params: { name: "home" } },
{
jsonrpc: "2.0",
id: 1,
method: "ui.press",
params: { key: "\u001b[9;5u" },
},
{
jsonrpc: "2.0",
id: 1,
method: "ui.press",
params: { key: "\u001b[C" },
},
{
jsonrpc: "2.0",
id: 1,
method: "ui.press",
params: { key: "\u001b[1;3B" },
},
])
} finally {
await server.stop(true)
await rm(root, { recursive: true, force: true })
}
})
async function sendState(root: string) {
return send(root, ["--command.ui.state"])
}
async function send(root: string, args: string[]) {
const child = Bun.spawn([process.execPath, resolve("src/cli/index.ts"), "send", ...args], {
cwd: resolve("."),
env: {
...process.env,
DRIVE_REGISTRY_DIR: join(root, "registry"),
TMPDIR: root,
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
})
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
return { status, stdout, stderr }
}
+21 -4
View File
@@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { I18nProvider } from "@opencode-ai/ui/context"
import { pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import {
pluralCategory,
pluralKey,
type UiI18nParams,
type UiI18nPluralKey,
type UiTranslate,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
@@ -58,20 +65,30 @@ function detectLocale() {
function UiI18nBridge(props: ParentProps) {
const locale = createMemo(() => detectLocale())
const zh = uiZh as Partial<Record<string, string>>
const t = (key: keyof typeof uiEn, params?: UiI18nParams) => {
const translate = (key: keyof typeof uiEn, params?: UiI18nParams) => {
const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key]
const text = value ?? String(key)
return resolveTemplate(text, params)
}
const t = translate as UiTranslate
const pluralForm = (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => {
const candidate = pluralKey(key, category)
const fallback = pluralKey(key, "other")
const value =
locale() === "zh"
? (zh[candidate] ?? zh[fallback] ?? uiEn[candidate] ?? uiEn[fallback])
: (uiEn[candidate] ?? uiEn[fallback])
return resolveTemplate(value ?? fallback, params)
}
const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) =>
t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count })
pluralForm(key, pluralCategory(locale(), count), { ...params, count })
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
})
return <I18nProvider value={{ locale, t, plural }}>{props.children}</I18nProvider>
return <I18nProvider value={{ locale, t, plural, pluralForm }}>{props.children}</I18nProvider>
}
export default function App() {
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -29,7 +29,7 @@ import { WebSearchGroup } from "./groups/websearch.js"
import { McpGroup } from "./groups/mcp.js"
import { CredentialGroup } from "./groups/credential.js"
import { ProjectGroup } from "./groups/project.js"
import { WorktreeGroup } from "./groups/worktree.js"
import { ProjectCopyGroup } from "./groups/project-copy.js"
import { VcsGroup } from "./groups/vcs.js"
import { MigrationGroup } from "./groups/migration.js"
import { ConfigGroup } from "./groups/config.js"
@@ -52,6 +52,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
@@ -88,7 +89,6 @@ type ApiGroups<
| typeof ServerGroup
| typeof DebugGroup
| typeof MigrationGroup
| typeof WorktreeGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -172,7 +172,7 @@ const makeApiFromGroup = <
.add(ShellGroup.middleware(locationMiddleware))
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
.add(ReferenceGroup.middleware(locationMiddleware))
.add(WorktreeGroup)
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(VcsGroup.middleware(locationMiddleware))
.add(DebugGroup)
.add(MigrationGroup)
+1 -1
View File
@@ -60,7 +60,7 @@ export const groupNames = {
"server.question": "question",
"server.reference": "reference",
"server.project": "project",
"server.worktree": "worktree",
"server.projectCopy": "projectCopy",
"server.vcs": "vcs",
"server.config": "config",
} as const
@@ -0,0 +1,56 @@
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
import { Project } from "@opencode-ai/schema/project"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const root = "/api/experimental/project/:projectID/copy"
export class ProjectCopyError extends Schema.ErrorClass<ProjectCopyError>("ProjectCopyError")(
{
name: Schema.Literal("ProjectCopyError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(ProjectCopy.CreateInput.fields, ["projectID", "sourceDirectory"]))
const RemovePayload = Schema.Struct(Struct.omit(ProjectCopy.RemoveInput.fields, ["projectID"]))
export const ProjectCopyGroup = HttpApiGroup.make("server.projectCopy")
.add(
HttpApiEndpoint.post("projectCopy.create", root, {
params: { projectID: Project.ID },
query: LocationQuery,
payload: CreatePayload,
success: ProjectCopy.Copy,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.create" })),
)
.add(
HttpApiEndpoint.delete("projectCopy.remove", root, {
params: { projectID: Project.ID },
query: LocationQuery,
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.remove" })),
)
.add(
HttpApiEndpoint.post("projectCopy.refresh", `${root}/refresh`, {
params: { projectID: Project.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: ProjectCopyError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(OpenApi.annotations({ identifier: "v2.projectCopy.refresh" })),
)
.annotateMerge(OpenApi.annotations({ title: "projectCopy", description: "Project copy management routes." }))
+16 -1
View File
@@ -31,9 +31,24 @@ export const ProjectGroup = HttpApiGroup.make("server.project")
}),
),
)
.add(
HttpApiEndpoint.get("project.directories", `${root}/:projectID/directories`, {
params: { projectID: Project.ID },
query: LocationQuery,
success: Project.Directories,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.project.directories",
summary: "List project directories",
description: "List known local absolute directories for a project.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "project",
description: "Project routes.",
description: "Location-scoped project routes.",
}),
)
-76
View File
@@ -1,76 +0,0 @@
import { Project } from "@opencode-ai/schema/project"
import { Worktree } from "@opencode-ai/schema/worktree"
import { Schema, Struct } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
const root = "/api/experimental/project/:projectID/worktree"
export class WorktreeError extends Schema.ErrorClass<WorktreeError>("WorktreeError")(
{
name: Schema.Literal("WorktreeError"),
data: Schema.Struct({
message: Schema.String,
forceRequired: Schema.optional(Schema.Boolean),
}),
},
{ httpApiStatus: 400 },
) {}
const CreatePayload = Schema.Struct(Struct.omit(Worktree.CreateInput.fields, ["projectID"]))
const RemovePayload = Schema.Struct(Struct.omit(Worktree.RemoveInput.fields, ["projectID"]))
export const WorktreeGroup = HttpApiGroup.make("server.worktree")
.add(
HttpApiEndpoint.get("worktree.list", root, {
params: { projectID: Project.ID },
success: Worktree.List,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.list",
summary: "List worktrees",
description: "List known local worktrees for a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.create", root, {
params: { projectID: Project.ID },
payload: CreatePayload,
success: Worktree.Info,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.create",
summary: "Create worktree",
description: "Create a worktree for a project.",
}),
),
)
.add(
HttpApiEndpoint.delete("worktree.remove", root, {
params: { projectID: Project.ID },
payload: RemovePayload,
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.remove",
summary: "Remove worktree",
description: "Remove a managed worktree from a project.",
}),
),
)
.add(
HttpApiEndpoint.post("worktree.refresh", `${root}/refresh`, {
params: { projectID: Project.ID },
success: HttpApiSchema.NoContent,
error: WorktreeError,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.worktree.refresh",
summary: "Refresh worktrees",
description: "Reconcile stored worktrees with the project repositories.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "worktree", description: "Project worktree management routes." }))

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