Compare commits

..
33 Commits
Author SHA1 Message Date
Dax Raad 78e76bfdec fix(core): give timelines independent identities 2026-09-06 01:21:42 -04:00
Dax Raad 26d2d06fba refactor(core): keep fork selection inside projection 2026-09-05 21:25:19 -04:00
Dax Raad c3f874747e feat(core): share session history through timelines 2026-09-05 21:12:15 -04:00
Dax Raad 52685d4517 fix(tui): simplify home update notices 2026-09-05 02:17:09 -04:00
Luke Parker 30d1049942 fix(app): load worktree inventory on demand and cap concurrent server requests (#47441) 2026-09-05 05:25:43 +00:00
Stefan Avramandslickstef11 21adcb4969 fix(console): route enterprise forms to Chatwoot (#47437)
Co-authored-by: slickstef11 <stefanavram22@gmail.com>
2026-09-05 00:05:40 -04:00
Kit Langton c72b535dee fix(tui): reload local plugin dependency graphs (#47388)
Reload statically discovered local plugin dependencies through native module caches while preserving shared package identities and best-effort last-good registrations.
2026-09-04 23:41:02 -04:00
opencode-agent[bot]andjlongster 8d1a9799f4 fix(tui): remove completion notice links (#47426)
Co-authored-by: jlongster <17031+jlongster@users.noreply.github.com>
2026-09-04 22:46:28 -04:00
David Hill 86ba09c6e0 fix(app): increase vertical tabs minimum width (#47424) 2026-09-05 12:10:12 +10:00
opencode-agent[bot]andHona 41cb354c3e fix(app): preserve vertical navigation label and icon widths (#47418)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-05 11:30:12 +10:00
James Long 23f3f8b6ca fix(tui): reference update command (#47415) 2026-09-04 20:43:44 -04:00
Aiden Cline baab05727d fix(core): compare Codex GPT versions by major and minor (#47404) 2026-09-04 19:22:12 -05:00
Aiden Cline 541937d124 fix(core): clarify how to call search in Code Mode guidance (#47360) 2026-09-04 18:50:47 -05:00
David Hill 0da55f1cef fix(app): list OpenCode Go before Zen (#47386) 2026-09-05 09:38:59 +10:00
opencode-agent[bot] ac76382211 chore: update nix node_modules hashes 2026-09-04 23:23:36 +00:00
James Long c30285c148 feat(worktree): support configurable plugin strategies (#47358) 2026-09-04 19:07:40 -04:00
opencode-agent[bot] b274224af1 fix(app): keep pending worktree composer styling consistent (#47377) 2026-09-04 22:59:21 +00:00
opencode-agent[bot] 7a050a19a1 fix(app): apply worktree message color during preparation (#47379) 2026-09-04 22:53:43 +00:00
Luke Parker 16601775f1 fix(app): align desktop worktree location with TUI (#47370) 2026-09-04 22:12:15 +00:00
James Long 0991e8b5a5 fix(tui): clarify update notifications (#47372) 2026-09-04 18:03:12 -04:00
James Long 6d791dfe67 feat(tui): add plugin-owned session panels (#47150) 2026-09-04 17:37:45 -04:00
Dax f268956c75 fix(tui): preview always-allow patterns without extra confirmation (#47369) 2026-09-04 21:34:58 +00:00
James Long 3290a39667 feat(tui): add manual update dialog (#47364) 2026-09-04 17:11:35 -04:00
James Long 51d69b26a0 refactor(tui): support reactive theme contexts (#47148) 2026-09-04 16:42:07 -04:00
James Long 8ff1ef1a62 fix(tui): anchor diff file menus to the screen (#47147) 2026-09-04 16:23:16 -04:00
James Long 90cd910f52 feat(tui): add shared interactivity context (#47144) 2026-09-04 16:07:11 -04:00
Dax Raad c3342ca812 chore: empty commit 2026-09-04 14:23:12 -04:00
Kit Langton 7ca047b2b9 fix(core): prioritize compaction over pending steers (#47340) 2026-09-04 13:23:55 -04:00
opencode-agent[bot]andnexxeln b78c2ea7b4 fix(tui): undo pending prompts and active sessions (#47343)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 22:52:03 +05:30
James Long 0e143437c8 feat(cli): restore automatic update policy (#47161) 2026-09-04 12:58:30 -04:00
opencode-agent[bot]andrekram1-node bff58fc387 feat(tui): show retry countdown (#47145)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-04 11:24:04 -05:00
Shoubhit Dash a26a978051 fix(ai): tolerate out-of-band responses notifications (#47316) 2026-09-04 21:36:29 +05:30
opencode-agent[bot]andnexxeln 218a0dde97 fix(tui): clear stale error styling on resumed subagents (#47295)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-04 21:29:27 +05:30
206 changed files with 7558 additions and 2878 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- 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 event replay ownership 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.
+2
View File
@@ -695,6 +695,8 @@
"effect": "catalog:",
},
"devDependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
+2
View File
@@ -235,6 +235,7 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAIL")
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
@@ -263,6 +264,7 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ENTERPRISE_SALES_INBOX_EMAIL,
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-J7xJ1+x+f13zso+UMP1YYzkjFM0LiHZURmf8HhO3X4M=",
"aarch64-linux": "sha256-CF7BiObbwjFT4BV5FUqB535fqoPuXuExKpERcev/Re0=",
"aarch64-darwin": "sha256-ts3zgHiPeQcrehST1ikr+BNwlF+3JDn0/IUeJSAOB3o=",
"x86_64-darwin": "sha256-awKiMBuDMrapw3Lrm39TbE+ILJZ1P8R8sqYJEWapSf8="
"x86_64-linux": "sha256-IDORw4Nup1Yj+RZSLGo0pDnwrkveYA8GvYQVNTJUnMM=",
"aarch64-linux": "sha256-jxjyAI2imF7csdZNUqvs9mFj8SnYqGg1CapEEk15rxA=",
"aarch64-darwin": "sha256-Hbh+cw2DPOJpYCDjasK6m0SLqznyAw1ODKSb7w87GG8=",
"x86_64-darwin": "sha256-Aj0/MtMPoeccrN36mu2wUM6gyRKaTF2AbjZ0/wZtKnA="
}
}
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
responseID = created
return { type: "frame", frame }
}
// Keepalives carry no response state and may arrive before response.created.
if (event.type === "keepalive") return { type: "frame", frame }
// Keepalives and provider notifications carry no response state and may precede response.created.
if (!event.type.startsWith("response.")) return { type: "frame", frame }
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("tolerates keepalive frames before response.created", () =>
it.effect("tolerates keepalive and provider notifications before response.created", () =>
Effect.gen(function* () {
const webSocket = WebSocketTransport.makeDirect({
open: () =>
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
ProviderShared.encodeJson({
type: "response.completed",
@@ -86,7 +86,12 @@ async function mockServers(page: Page, requests: string[]) {
}
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
}
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: current.directory,
project: { id: current.projectID, directory: current.directory, canonical: current.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: current.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: current.directory },
@@ -490,20 +490,24 @@ async function openDraft(
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) {
if (path === "/api/worktree") {
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
calls.push("worktree")
worktreeRequests.push(request.postDataJSON())
}
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
await page.route(`**/api/worktree/${projectID}`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
},
)
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: Record<string, unknown> = route.request().postDataJSON()
@@ -396,7 +396,12 @@ async function mockServers(
return json(route, { data: [], cursor: {} })
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/inbox`))
return json(route, { data: [] })
if (url.pathname === "/api/location") return json(route, { directory })
if (url.pathname === "/api/location")
return json(route, {
directory,
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
if (url.pathname === "/api/vcs")
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
@@ -87,16 +87,24 @@ test("recording a new session shortcut stays in settings until recording finishe
test("workspaces opens without waiting for inventory or sessions", async ({ page }) => {
const inventory = Promise.withResolvers<void>()
const sessions = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await inventory.promise
await route.fallback()
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await inventory.promise
await route.fallback()
},
)
await page.route("**/api/session?*", async (route) => {
if (new URL(route.request().url()).searchParams.has("directory")) await sessions.promise
await route.fallback()
})
const settings = page.getByTestId("settings-screen")
const requested = page.waitForRequest((request) => new URL(request.url()).pathname.startsWith("/api/worktree/"))
const requested = page.waitForRequest(
(request) =>
new URL(request.url()).pathname === "/api/worktree" &&
new URL(request.url()).searchParams.get("location[directory]") === directory &&
request.method() === "GET",
)
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await requested
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
@@ -110,16 +118,57 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
const refresh = Promise.withResolvers<void>()
await page.route("**/api/worktree/*", async (route) => {
await refresh.promise
await route.fallback()
})
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
await refresh.promise
await route.fallback()
},
)
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText("Workspace 1 session", { exact: true })).toBeVisible()
refresh.resolve()
})
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
const removed = new Set<string>()
await page.route(
(url) => url.pathname === "/api/worktree",
async (route) => {
if (route.request().method() === "GET") {
return route.fulfill({
json: [
{ directory },
...sandboxes.filter((item) => !removed.has(item)).map((directory) => ({ directory, strategy: "git" })),
],
})
}
if (route.request().method() === "DELETE") {
removed.add(route.request().postDataJSON().directory)
return route.fulfill({ status: 204 })
}
return route.fallback()
},
)
const settings = page.getByTestId("settings-screen")
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
await expect(settings.getByText(sandboxes[0], { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete worktree "workspace-1"?', exact: true }).click()
const confirmation = page.getByRole("dialog", { name: "Delete worktree", exact: true })
const remove = confirmation.getByRole("button", { name: "Delete worktree", exact: true })
await expect(remove).toBeEnabled()
const deleting = page.waitForRequest(
(request) => new URL(request.url()).pathname === "/api/worktree" && request.method() === "DELETE",
)
await remove.click()
const request = await deleting
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
})
test("extensions opens without waiting for MCPs", async ({ page }) => {
const mcps = Promise.withResolvers<void>()
await page.route("**/api/mcp", async (route) => {
@@ -225,13 +225,81 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
await page.mouse.down()
await page.mouse.move(resized.x - 200, resized.y + resized.height / 2)
await page.mouse.up()
await expect(sidebar).toHaveCSS("width", "130px")
await expect(sidebar).toHaveCSS("width", "140px")
await tabB.click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabB).toBeVisible()
})
for (const count of [0, 26]) {
test(`vertical navigation labels and icons use the available width with ${count} tabs`, async ({
page,
}, testInfo) => {
await mockServer(page)
await page.addInitScript(
({ server, directory, count }) => {
localStorage.setItem(
"settings.v3",
JSON.stringify({
appearance: { tabLayout: "vertical" },
keybinds: { "home.toggle": "alt+home", "tab.new": "ctrl+shift+n" },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify(
Array.from({ length: count }, (_, index) => ({
type: "draft",
server,
directory,
draftID: `draft_navigation_${index}`,
})),
),
)
},
{ server, directory: sessionA.directory, count },
)
await page.goto("/")
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
await expect(sidebar).toHaveCSS("width", "260px")
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(count)
for (const width of [260, 180, 140]) {
if (width !== 260) {
const handle = await sidebar.locator('[data-component="resize-handle"]').boundingBox()
const bounds = await sidebar.boundingBox()
if (!handle || !bounds) throw new Error("vertical tab sidebar has no bounding box")
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2)
await page.mouse.down()
await page.mouse.move(handle.x + handle.width / 2 + width - bounds.width, handle.y + handle.height / 2)
await page.mouse.up()
}
await expect(sidebar).toHaveCSS("width", `${width}px`)
await testInfo.attach(`navigation-${count}-${width}`, {
body: await sidebar.screenshot(),
contentType: "image/png",
})
for (const name of ["Home", "New session"]) {
const button = sidebar.getByRole("button", { name, exact: true })
const label = button.getByText(name, { exact: true })
await expect(label).toBeVisible()
await expect
.poll(() => label.evaluate((element) => element.scrollWidth - element.clientWidth), { message: name })
.toBeLessThanOrEqual(1)
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await button.hover()
await expect(button.locator('span[aria-hidden="true"]')).toBeVisible()
await expect(button.locator('[data-slot="icon-svg"]')).toHaveCSS("width", "16px")
await expect
.poll(() => button.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(1)
await page.getByRole("main").hover()
}
}
})
}
for (const direction of ["ltr", "rtl"]) {
test(`vertical tabs keep Settings pinned while scrolling in ${direction}`, async ({ page }, testInfo) => {
await mockServer(page)
@@ -359,9 +427,9 @@ for (const profile of [
const hint = button.locator('span[aria-hidden="true"]')
await expect(hint).toHaveText(row.shortcut)
await expect(hint.getByText(row.shortcut, { exact: true })).toHaveCSS("direction", "ltr")
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
await button.hover()
await expect(hint).toHaveCSS("opacity", "1")
await expect(hint).toBeVisible()
await expect
.poll(() =>
hint.evaluate((element) => {
@@ -373,7 +441,7 @@ for (const profile of [
)
.toBeCloseTo(8, 1)
await page.getByRole("main").hover()
await expect(hint).toHaveCSS("opacity", "0")
await expect(hint).toBeHidden()
}
const home = sidebar.locator('[data-action="vertical-tabs-home"]')
@@ -381,10 +449,10 @@ for (const profile of [
await home.focus()
await page.keyboard.press("Tab")
await expect(newSession).toBeFocused()
await expect(newSession.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(newSession.locator('span[aria-hidden="true"]')).toBeVisible()
await page.keyboard.press("Shift+Tab")
await expect(home).toBeFocused()
await expect(home.locator('span[aria-hidden="true"]')).toHaveCSS("opacity", "1")
await expect(home.locator('span[aria-hidden="true"]')).toBeVisible()
})
}
@@ -560,7 +628,12 @@ async function mockServer(page: Page) {
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
)
}
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
if (url.pathname === "/api/location")
return json(route, {
directory: sessionA.directory,
project: { id: sessionA.projectID, directory: sessionA.directory, canonical: sessionA.directory },
})
if (url.pathname === "/api/worktree") return json(route, [{ directory: sessionA.directory }])
if (url.pathname === "/api/vcs")
return json(route, {
location: { directory: sessionA.directory },
@@ -97,7 +97,9 @@ for (const theme of ["light", "dark"] as const) {
const refreshed = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
view.worktrees.push({ directory: workspace, strategy: "git" })
view.events.push({
@@ -199,10 +201,13 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
events: () => events.splice(0),
})
// Keep authoritative inventory independent of the raw project's empty sandboxes.
await page.route(`**/api/worktree/${projectID}`, (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
})
await page.route(
(url) => url.pathname === "/api/worktree",
(route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({ json: worktrees, headers: { "access-control-allow-origin": "*" } })
},
)
if (draft)
await page.addInitScript(
({ root, server }) => {
@@ -222,7 +227,9 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
)
const loaded = page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/worktree/${projectID}` && response.request().method() === "GET",
new URL(response.url()).pathname === "/api/worktree" &&
new URL(response.url()).searchParams.get("location[directory]") === root &&
response.request().method() === "GET",
)
await page.goto(
draft ? "/new-session?draftId=draft_workspace_accent" : `/server/${base64Encode(server)}/session/${sessionID}`,
+4 -8
View File
@@ -71,27 +71,23 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
.add(
HttpApiEndpoint.get("worktreeList", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
success: Json,
}),
)
.add(
HttpApiEndpoint.post("worktreeCreate", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
payload: JsonPayload,
success: Json,
}),
)
.add(
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree/:projectID", {
params: { projectID: Schema.String },
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
success: NoContent,
}),
)
.add(
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/:projectID/refresh", {
params: { projectID: Schema.String },
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
success: NoContent,
}),
)
@@ -8,6 +8,7 @@
}
[data-color-scheme="dark"] [data-component="composer"][data-dock-border-underlay="true"],
[data-color-scheme="dark"] [data-component="session-composer-dock"] [data-component="composer"],
[data-color-scheme="dark"] [data-component="new-session"] [data-component="composer"] {
background: var(--v2-background-bg-layer-01);
}
+8
View File
@@ -34,6 +34,14 @@ export function createHomeController() {
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const ctx = focusedServerCtx()
const id = selectedProject()?.id
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void ctx.sync.worktrees.load(root)
})
function setSelection(next: HomeProjectSelection) {
layout.home.setSelection(next)
@@ -27,7 +27,7 @@ import { sessionLabel, sessionTitle } from "@/session/title"
import { showToast } from "@/shell/notifications/toast"
import { archiveHomeSession } from "./archive"
import type { HomeController } from "../model"
import { buildHomeSessionRecords, type HomeSessionRecord } from "./records"
import { buildHomeSessionRecords, homeProjectForSession, type HomeSessionRecord } from "./records"
export type { HomeSessionRecord } from "./records"
@@ -270,14 +270,7 @@ export function createHomeSessionsController(home: HomeController) {
},
create: home.project.openNewSession,
open: (session: SessionInfo, options?: OpenSessionOptions) => {
const directoryKey = pathKey(session.location.directory)
const project = home.project
.list()
.find(
(item) =>
pathKey(item.worktree) === directoryKey ||
item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey),
)
const project = homeProjectForSession(session, home.project.list())
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
@@ -36,4 +36,26 @@ describe("buildHomeSessionRecords", () => {
expect(records.map((record) => record.session.id)).toEqual(["a"])
})
test("labels a worktree session with its project before that project's inventory has loaded", () => {
const records = buildHomeSessionRecords({
sessions: () => [session("w", "/repo/a/.worktrees/feature", "project-a")],
projectDirectories: () => undefined,
projects: () => [{ ...opened, name: "Project A" }],
})
expect(records[0]?.project).toMatchObject({ id: "project-a", worktree: "/repo/a" })
expect(records[0]?.projectName).toBe("Project A")
})
test("prefers the added project whose directory matches over a sibling entry with the same ID", () => {
const nested = { id: "project-a", worktree: "/repo/a/packages/app", expanded: true } as LocalProject
const records = buildHomeSessionRecords({
sessions: () => [session("n", "/repo/a/packages/app", "project-a")],
projectDirectories: () => undefined,
projects: () => [opened, nested],
})
expect(records[0]?.project.worktree).toBe("/repo/a/packages/app")
})
})
+16 -7
View File
@@ -22,13 +22,7 @@ export function buildHomeSessionRecords(input: {
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
.sort(compareSessionTime)
.map((session) => {
const directory = pathKey(session.location.directory)
const project = input
.projects()
.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? {
const project = homeProjectForSession(session, input.projects()) ?? {
id: session.projectID,
worktree: session.location.directory,
expanded: false,
@@ -36,3 +30,18 @@ export function buildHomeSessionRecords(input: {
return { session, project, projectName: displayName(project) }
})
}
// Worktree inventories load on demand, so a worktree session may not match any directory yet;
// the session's project ID still identifies its added project.
export function homeProjectForSession<T extends { id?: string; worktree: string; sandboxes?: readonly string[] }>(
session: SessionInfo,
projects: readonly T[],
) {
const directory = pathKey(session.location.directory)
return (
projects.find(
(item) =>
pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
) ?? projects.find((item) => item.id === session.projectID)
)
}
+15 -59
View File
@@ -34,8 +34,6 @@ export const ProviderTipSchema = Persistence.struct({
dismissedAt: Schema.Finite,
})
export const WorkspaceTipSchema = ProviderTipSchema
export function NewSessionView(props: {
composer: ComposerModel
project: PromptProjectController
@@ -101,15 +99,7 @@ export function NewSessionView(props: {
</div>
</div>
</div>
<NewSessionTips
workspaceEligible={
!!props.project.selected() &&
props.workspace.bar.visible() &&
props.workspace.selection.value() !== "create" &&
props.workspace.project.managed() === 0
}
onWorkspace={() => select("create")}
/>
<ProviderTip />
</div>
</div>
)
@@ -128,90 +118,56 @@ export function NewSessionStatus(props: { visible: boolean }) {
)
}
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
function ProviderTip() {
const language = useLanguage()
const dialog = useDialog()
const sdk = useWorkspaceLocation()
const providers = useProviders(() => sdk().directory)
const [providerState, setProviderState, , providerReady] = persisted(
const [persistedState, setPersistedState, , persistedReady] = persisted(
Persist.global("new-session.provider-tip"),
ProviderTipSchema,
{ dismissedAt: 0 },
)
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
Persist.global("new-session.workspace-tip"),
WorkspaceTipSchema,
{ dismissedAt: 0 },
)
const workspaceVisible = createMemo(
() =>
props.workspaceEligible &&
workspaceReady() &&
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
)
const providerVisible = createMemo(
const visible = createMemo(
() =>
providers.ready() &&
providerReady() &&
persistedReady() &&
providers.paid().length === 0 &&
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
)
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
if (providerVisible()) return "provider"
if (workspaceVisible()) return "workspace"
})
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
const [ref, setRef] = createSignal<HTMLDivElement>()
const presence = createPresence({
show: () => tip() !== undefined,
show: visible,
element: () => ref() ?? null,
})
const open = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
props.onWorkspace()
return
}
const openProviders = () => {
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
})
}
const dismiss = () => {
const current = tip()
if (!current) return
if (current === "workspace") {
setWorkspaceState("dismissedAt", Date.now())
return
}
setProviderState("dismissedAt", Date.now())
}
return (
<Show when={presence.present()}>
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
<div
ref={setRef}
data-component="new-session-tip"
data-visible={tip() !== undefined}
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
data-component="provider-tip"
data-visible={visible()}
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={open}
onClick={openProviders}
>
<span class="truncate">
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
</span>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<Tooltip
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
placement="top"
openDelay={1000}
value={language.t("common.dismiss")}
@@ -220,7 +176,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
type="button"
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out 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"
aria-label={language.t("common.dismiss")}
onClick={dismiss}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<Icon name="xmark-small" />
</button>
@@ -1,5 +1,5 @@
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
sameDirectory,
workspaceDefaultSelection,
workspaceDirectories,
workspaceSelectionDestination,
} from "@/workspaces/paths"
@@ -56,45 +56,6 @@ export function createNewSessionWorkspaceController(input: {
const current = projectID ? data.project.get(projectID) : undefined
return current ? normalizeProjectInfo(current) : undefined
})
const [worktrees, worktreeActions] = createResource(
() => currentProject()?.id,
async (projectID) => ({
projectID,
items: await serverSDK.api.worktree
.list({ projectID })
.catch(() => (currentProject()?.id === projectID ? currentProject()?.worktrees : undefined) ?? []),
}),
)
onCleanup(
serverSDK.event.listen((event) => {
if (event.type === "worktree.updated") void worktreeActions.refetch()
}),
)
const worktreeItems = createMemo(() => {
const project = currentProject()
if (!project) return []
const loaded = worktrees.latest
return loaded?.projectID === project.id ? loaded.items : project.worktrees
})
const worktreeDirectories = createMemo(() => {
const project = currentProject()
if (!project) return []
const directories = [
...worktreeItems().map((item) => item.directory),
...project.worktrees.map((item) => item.directory),
...(project.sandboxes ?? []),
]
return directories
.filter((directory) => !sameDirectory(project.worktree, directory))
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
})
const managedWorktrees = createMemo(() => {
const project = currentProject()
if (!project) return 0
return worktreeItems().filter(
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
).length
})
const visible = createMemo(() =>
resolveNewSessionGit({
projectVcs: currentProject()?.vcs,
@@ -105,9 +66,7 @@ export function createNewSessionWorkspaceController(input: {
const project = currentProject()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) || worktreeDirectories().some((item) => sameDirectory(item, worktree))
? worktree
: undefined
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = currentProject()
@@ -138,7 +97,7 @@ export function createNewSessionWorkspaceController(input: {
() => undefined,
)
const project = currentProject()
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
})
const branch = createMemo(() =>
@@ -163,11 +122,7 @@ export function createNewSessionWorkspaceController(input: {
workspace: createMemo(() => {
const project = currentProject()
const current = value()
return (
current === "create" ||
(!!project &&
(isWorkspaceDirectory(project, current) || worktreeDirectories().some((item) => sameDirectory(item, current))))
)
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => {
input.setSelectedWorktree(undefined)
@@ -187,8 +142,10 @@ export function createNewSessionWorkspaceController(input: {
},
project: {
root: projectRoot,
workspaces: worktreeDirectories,
managed: managedWorktrees,
workspaces: () => {
const project = currentProject()
return project ? workspaceDirectories(project) : []
},
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
@@ -1,6 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -26,9 +25,6 @@ export function PromptWorkspaceSelector(props: {
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
const branchTruncation = createTruncatedText()
const focusWorktreeSearch = () =>
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
@@ -38,8 +34,8 @@ export function PromptWorkspaceSelector(props: {
})
const icon = () => {
if (selected() === "main") return "monitor"
if (selected() === "create") return "plus"
return "outline-worktree"
if (selected() === "create") return "workspace-new"
return "workspace-isolated"
}
const select = (value: string) => {
pending = { type: "select", value }
@@ -70,14 +66,13 @@ export function PromptWorkspaceSelector(props: {
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<Tooltip
appearance={props.onboarding ? "large" : undefined}
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="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
<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>
@@ -94,10 +89,7 @@ export function PromptWorkspaceSelector(props: {
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 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
@@ -117,8 +109,14 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.local.tooltip")}
contentClass="max-w-[140px]"
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>
@@ -128,22 +126,24 @@ export function PromptWorkspaceSelector(props: {
</Show>
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="plus" />
<Tooltip
placement="right"
openDelay={800}
value={language.t("session.new.workspace.new.tooltip")}
contentClass="max-w-[140px]"
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<Icon 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>
</Menu.Item>
</Menu.Group>
<Show when={props.workspaces.length > 0}>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</>
}
>
<Menu.Separator class="h-[0.5px]" />
<Menu.Sub
gutter={0}
@@ -156,11 +156,10 @@ export function PromptWorkspaceSelector(props: {
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
focusWorktreeSearch()
requestAnimationFrame(() => searchInput?.focus())
}}
>
<Menu.SubTrigger
onClick={focusWorktreeSearch}
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
@@ -171,7 +170,7 @@ export function PromptWorkspaceSelector(props: {
focusSearch = true
}}
>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
@@ -206,7 +205,7 @@ export function PromptWorkspaceSelector(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item onSelect={() => select(workspace)}>
<Icon name="outline-worktree" />
<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" />
@@ -214,11 +213,6 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
)}
</For>
<Show when={search.workspaces.trim() && workspaces().length === 0}>
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
{language.t("session.new.workspace.search.empty")}
</div>
</Show>
<Menu.Separator class="h-[0.5px]" />
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
@@ -238,7 +232,6 @@ export function PromptWorkspaceSelector(props: {
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
disabled={!branchTruncation.truncated()}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
@@ -252,7 +245,7 @@ export function PromptWorkspaceSelector(props: {
>
<Menu.Trigger 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 transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span ref={branchTruncation.observe} class="min-w-0 truncate">
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
@@ -326,7 +319,6 @@ export function PromptWorkspaceSelector(props: {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const truncation = createTruncatedText()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
@@ -346,27 +338,15 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
<Tooltip
placement="top"
value={value()}
disabled={!truncation.truncated()}
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 ref={truncation.observe} class="min-w-0 truncate">
{value()}
</span>
<span class="min-w-0 truncate">{value()}</span>
</div>
</Tooltip>
)}
</Show>
)
}
function createTruncatedText() {
const [truncated, setTruncated] = createSignal(false)
return {
truncated,
observe: (element: HTMLSpanElement) =>
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
}
}
@@ -0,0 +1,6 @@
import { expect, test } from "bun:test"
import { popularProviders } from "./order"
test("lists OpenCode Go before Zen and other popular providers", () => {
expect(popularProviders.slice(0, 2)).toEqual(["opencode-go", "opencode"])
})
+1 -1
View File
@@ -1,6 +1,6 @@
export const popularProviders = [
"opencode",
"opencode-go",
"opencode",
"anthropic",
"github-copilot",
"openai",
@@ -110,7 +110,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
active: undefined as string | undefined,
connecting: undefined as string | undefined,
})
const featured = ["opencode", "opencode-go", "anthropic", "openai", "google", "openrouter", "vercel"]
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
const all = createMemo(() => {
language.locale()
+1 -1
View File
@@ -13,7 +13,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ModelTooltip } from "./tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
const featuredProviders = ["opencode", "opencode-go", "openai", "anthropic", "google", "github-copilot"]
const featuredProviders = ["opencode-go", "opencode", "openai", "anthropic", "google", "github-copilot"]
const displayModelName = (name: string) => name.replace(/\s+(?:\(free\)|free)$/i, "")
export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
+11 -27
View File
@@ -659,7 +659,6 @@ export const dict = {
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
"session.tab.session": "Session",
"session.tab.files": "Files",
@@ -1239,43 +1238,30 @@ export const dict = {
"workspace.new": "New worktree",
"common.viewAll": "View all",
"session.new.workspace.local.tooltip": "Uses projects current checkout",
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
"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.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search worktrees",
"session.new.workspace.search.empty": "No matching worktrees",
"settings.tab.workspaces": "Worktrees",
"settings.workspaces.description": "Review worktrees and manage disk usage",
"settings.workspaces.filter.all": "All projects",
"settings.workspaces.empty": "No worktrees yet",
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
"settings.workspaces.empty": "No worktrees",
"settings.workspaces.count.one": "{{count}} worktree",
"settings.workspaces.count.other": "{{count}} worktrees",
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
"settings.workspaces.sessions.filtered.one": "{{count}} session",
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
"settings.workspaces.lastActiveSession": "Last active session",
"settings.workspaces.deleteAll": "Delete all worktrees",
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
"settings.workspaces.deleteWithoutSessions.confirm.one":
"This will permanently delete {{count}} worktree without linked sessions.",
"settings.workspaces.deleteWithoutSessions.confirm.other":
"This will permanently delete {{count}} worktrees without linked sessions.",
"settings.workspaces.deleteWithoutSessions.warning":
"Worktrees with unmerged changes or active locations will be kept.",
"settings.workspaces.delete.button": "Delete worktrees",
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
"settings.workspaces.delete.warning":
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
"settings.workspaces.deleteAll.warning":
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
"settings.workspaces.delete.blocked.linked":
"Linked sessions will remain, but their working directory will no longer exist.",
"Linked sessions will remain, but their working directory will be permanently removed.",
"settings.workspaces.default.title": "Default environment",
"settings.workspaces.default.description": "Choose where new sessions start",
"settings.workspaces.default.lastUsed": "Last used per project",
@@ -1286,10 +1272,9 @@ export const dict = {
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating worktree",
"workspace.lifecycle.created": "Worktree created",
"workspace.lifecycle.deleting": "Deleting…",
"workspace.lifecycle.starting": "Starting session",
"workspace.onboarding.title": "Isolate sessions with worktrees",
"workspace.onboarding.description": "Each gets its own checkout",
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
"workspace.lifecycle.moving": "Moving to worktree",
"workspace.lifecycle.set": "Worktree set",
"session.summary.title": "Session details",
@@ -1308,10 +1293,9 @@ export const dict = {
"workspace.status.checking": "Checking for unmerged changes…",
"workspace.status.error": "Unable to verify git status.",
"workspace.status.clean": "No unmerged changes detected.",
"workspace.status.dirty": "Unmerged changes will be lost.",
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
"workspace.delete.title": "Delete worktree",
"workspace.delete.confirm": "Delete {{name}}”?",
"workspace.delete.location": "Location",
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
"workspace.delete.button": "Delete worktree",
"workspace.reset.title": "Reset worktree",
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
import { ModelSelectionSchema } from "@/providers/models/selection"
import { Persistence } from "@/runtime/persistence/schema"
import { FileViewsSchema } from "@/workspaces/files/view-cache"
@@ -12,7 +12,6 @@ describe("persisted consumer schemas", () => {
test("onboarding and provider tip retain defaults and validate stored values", () => {
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
expect(onboarding({})).toEqual({ used: false })
expect(onboarding({ used: "true" })).toEqual({ used: false })
expect(onboarding({ used: true })).toEqual({ used: true })
@@ -20,7 +19,6 @@ describe("persisted consumer schemas", () => {
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
})
test("collapse records recover malformed entries without losing valid siblings", () => {
+3 -1
View File
@@ -6,6 +6,7 @@ import { createApiForServer, type ServerApi } from "@/runtime/server/api"
import { usePlatform } from "@/runtime/platform/platform"
import { ServerConnection } from "./registry"
import { createRefCountMap } from "@/runtime/server/refcount"
import { createRequestQueue } from "@/runtime/server/request-queue"
import { ServerScope } from "@/runtime/server/scope"
import { useServer } from "./current"
@@ -114,8 +115,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
const build = (http: ServerConnection.HttpBase) => {
const api = createApiForServer({ server: http, fetch: input.fetch })
const api = createApiForServer({ server: http, fetch: queue.fetch })
return { http, api, pty: createPtyClient(api, { url: http.url }) }
}
const state = { current: build(input.http) }
@@ -6,9 +6,7 @@ import { bootstrapGlobal, loadPathQuery, loadProjectsQuery } from "./bootstrap"
import { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerSync } from "@/runtime/server/sync"
type ProjectApi = ServerApi["project"]
type WorktreeApi = ServerApi["worktree"]
import { worktreeInventoryKey } from "@/workspaces/inventory"
test("bootstraps projects through the native store setter and preserves subsequent updates", async () => {
const api = OpenCode.make({
@@ -23,7 +21,6 @@ test("bootstraps projects through the native store setter and preserves subseque
})
if (url.pathname === "/api/project")
return Response.json([{ id: "project", canonical: "/repo", time: { created: 1, updated: 1 }, sandboxes: [] }])
if (url.pathname === "/api/worktree") return Response.json([{ directory: "/repo" }])
throw new Error(`Unexpected request: ${url.pathname}`)
},
{ preconnect() {} },
@@ -50,6 +47,18 @@ test("bootstraps projects through the native store setter and preserves subseque
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project.map((project) => [project.id, project.worktree])).toEqual([["project", "/repo"]])
expect(store.config).toEqual({})
// A refetch keeps the inventory a view already loaded for this project.
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
await bootstrapGlobal({ serverAPI: api, scope: ServerScope.local, setGlobalStore: setStore, queryClient })
expect(store.project[0]?.sandboxes).toEqual(["/repo/feature"])
expect(store.project[0]?.worktrees).toEqual([
{ directory: "/repo" },
{ directory: "/repo/feature", strategy: "git" },
])
} finally {
queryClient.clear()
}
@@ -79,58 +88,39 @@ describe("query keys", () => {
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
})
test("loads projects from the current endpoint", async () => {
const calls: string[] = []
const projects = {
list: async () => [
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
calls.push(projectID)
return [
{ directory: `/${projectID}` },
{ directory: `/${projectID}/clone` },
{ directory: `/${projectID}/copy`, strategy: "git" },
]
},
} as unknown as WorktreeApi
test("loads project metadata without enumerating any project's worktrees", async () => {
const requests: string[] = []
const api = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(new Request(input, init).url)
requests.push(url.pathname)
if (url.pathname !== "/api/project") throw new Error(`Unexpected request: ${url}`)
return Response.json([
...Array.from({ length: 300 }, (_, index) => ({
id: `historical-${index.toString().padStart(3, "0")}`,
canonical: `/history/${index}`,
time: { created: 1, updated: 1 },
sandboxes: [],
})),
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: ["/a/legacy"] },
{ id: "test", canonical: "/tmp/opencode-test-1", time: { created: 1, updated: 1 }, sandboxes: [] },
])
},
{ preconnect() {} },
),
})
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api.project))
expect(result.map((project) => project.id)).toEqual(["a", "b"])
expect(result.map((project) => project.sandboxes)).toEqual([
["/a/clone", "/a/copy"],
["/b/clone", "/b/copy"],
])
expect(result.map((project) => project.worktrees)).toEqual([
[{ directory: "/a" }, { directory: "/a/clone" }, { directory: "/a/copy", strategy: "git" }],
[{ directory: "/b" }, { directory: "/b/clone" }, { directory: "/b/copy", strategy: "git" }],
])
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
{ id: "a", canonical: "/a", time: { created: 1, updated: 1 }, sandboxes: [] },
{ id: "b", canonical: "/b", time: { created: 1, updated: 1 }, sandboxes: [] },
],
} as unknown as ProjectApi
const worktrees = {
list: async ({ projectID }: { projectID: string }) => {
if (projectID === "b") throw new Error("unavailable")
return [{ directory: "/a/copy", strategy: "git" as const }]
},
} as unknown as WorktreeApi
const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, projects, worktrees))
expect(result.map((project) => ({ id: project.id, sandboxes: project.sandboxes }))).toEqual([
{ id: "a", sandboxes: ["/a/copy"] },
{ id: "b", sandboxes: [] },
expect(requests).toEqual(["/api/project"])
expect(result).toHaveLength(302)
expect(result.slice(0, 2)).toMatchObject([
{ id: "a", worktree: "/a", sandboxes: ["/a/legacy"], worktrees: [{ directory: "/a" }] },
{ id: "b", worktree: "/b", sandboxes: [], worktrees: [{ directory: "/b" }] },
])
expect(result.some((project) => project.id === "test")).toBe(false)
})
})
@@ -15,8 +15,7 @@ import { cmp, normalizeProjectInfo } from "./utils"
import { formatServerError } from "@/runtime/server/errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import type { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { sameDirectory } from "@/workspaces/paths"
import { withWorktreeInventory, worktreeInventoryKey } from "@/workspaces/inventory"
type GlobalStore = {
path: Path
@@ -64,42 +63,21 @@ type ProjectApi = {
readonly list: () => Promise<ProjectListOutput>
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
}
type WorktreeApi = Pick<ServerApi["worktree"], "list">
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
// Metadata only. Worktree inventories load per project when a view shows it (see workspaces/inventory).
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi) =>
queryOptions({
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
projects.list().then(async (items) => {
return (
await Promise.all(
items
.filter((project) => !!project?.id)
.map(async (project) => {
const directories = await worktrees
.list({ projectID: project.id })
.catch(() => [
{ directory: project.canonical },
...(project.sandboxes ?? [])
.filter((directory) => !sameDirectory(project.canonical, directory))
.map((directory) => ({ directory })),
])
return normalizeProjectInfo({
...project,
sandboxes: directories
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.canonical, directory)),
worktrees: directories,
})
}),
)
)
projects.list().then((items) =>
items
.filter((project) => !!project?.id)
.map(normalizeProjectInfo)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
}),
.sort((a, b) => cmp(a.id, b.id)),
),
),
})
@@ -107,7 +85,6 @@ export async function bootstrapGlobal(input: {
serverAPI: {
readonly location: LocationApi
readonly project: ProjectApi
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
@@ -117,9 +94,17 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)).then((data) =>
input.setGlobalStore(
"project",
data.map((project) =>
withWorktreeInventory(
project,
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
),
),
),
),
]
await runAll(slow)
}
@@ -0,0 +1,113 @@
import { describe, expect, test } from "bun:test"
import { createRequestQueue } from "./request-queue"
function setup(input?: { limit?: number; stallMs?: number }) {
const pending: Array<{ url: string; resolve: () => void }> = []
const logs: Array<{ message: string; data: Record<string, unknown> }> = []
let clock = 0
const queue = createRequestQueue({
limit: input?.limit ?? 2,
stallMs: input?.stallMs,
now: () => clock,
log: (message, data) => logs.push({ message, data }),
fetch: Object.assign(
(resource: RequestInfo | URL) =>
new Promise<Response>((resolve) => {
pending.push({ url: new Request(resource).url, resolve: () => resolve(new Response("ok")) })
}),
{ preconnect() {} },
),
})
const settle = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
return { queue, pending, logs, settle, tick: (ms: number) => (clock += ms) }
}
describe("createRequestQueue", () => {
test("caps concurrent requests and starts queued ones as slots free up", async () => {
const input = setup()
const responses = ["/api/a", "/api/b", "/api/c"].map((path) => input.queue.fetch(`http://server${path}`))
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b"])
expect(input.queue.queued()).toBe(1)
input.pending[0]!.resolve()
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/a", "/api/b", "/api/c"])
input.pending.forEach((item) => item.resolve())
await Promise.all(responses)
expect(input.queue.inflight()).toBe(0)
})
test("never counts the event stream against the budget", async () => {
const input = setup({ limit: 1 })
void input.queue.fetch("http://server/api/session")
void input.queue.fetch("http://server/api/event")
await input.settle()
expect(input.pending.map((item) => new URL(item.url).pathname).toSorted()).toEqual(["/api/event", "/api/session"])
expect(input.queue.inflight()).toBe(1)
})
test("aborted requests leave the queue without being sent", async () => {
const input = setup({ limit: 1 })
const controller = new AbortController()
void input.queue.fetch("http://server/api/first")
const aborted = input.queue.fetch("http://server/api/second", { signal: controller.signal })
controller.abort()
await input.settle()
input.pending[0]!.resolve()
await expect(aborted).rejects.toBeInstanceOf(DOMException)
expect(input.pending.map((item) => new URL(item.url).pathname)).toEqual(["/api/first"])
expect(input.queue.inflight()).toBe(0)
})
test("a burst that drains promptly is not thrashing", async () => {
const input = setup({ stallMs: 5 })
const responses = Array.from({ length: 12 }, (_, index) => input.queue.fetch(`http://server/api/${index}`))
await input.settle()
expect(input.queue.queued()).toBe(10)
// Drain two at a time before the stall threshold elapses.
for (let round = 0; round < 6; round++) {
input.pending.splice(0).forEach((item) => item.resolve())
await input.settle()
}
await Promise.all(responses)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([])
})
test("logs what is in flight and queued once per burst after requests stall", async () => {
const input = setup({ stallMs: 5 })
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fa").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fb").catch(() => undefined)
input.tick(50)
input.queue.fetch("http://server/api/worktree?location[directory]=%2Fc").catch(() => undefined)
input.tick(100)
input.queue.fetch("http://server/api/health").catch(() => undefined)
expect(input.logs).toEqual([])
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toEqual([
{
message: "server thrashing detected",
data: {
limit: 2,
inflight: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fa", ms: 2_200 },
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fb", ms: 2_150 },
],
queued: [
{ method: "GET", url: "http://server/api/worktree?location[directory]=%2Fc", ms: 2_100 },
{ method: "GET", url: "http://server/api/health", ms: 2_000 },
],
},
},
])
// Still stalled within the rate limit: no repeat.
input.tick(2_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(1)
input.tick(10_000)
await new Promise((resolve) => setTimeout(resolve, 20))
expect(input.logs).toHaveLength(2)
})
})
@@ -0,0 +1,84 @@
type Entry = { method: string; url: string; at: number }
// Chromium allows six connections per origin. The event stream holds one for the life of the
// connection and health probes use their own fetch, so the app's API calls stay below that or
// a burst stalls probes and user actions inside the browser where nothing can observe it.
export const requestQueueLimit = 4
// A mount legitimately fires a dozen requests at once; only a request that has waited this long
// for a slot indicates the server is not keeping up.
export const requestStallMs = 2_000
export function createRequestQueue(input: {
fetch: typeof globalThis.fetch
limit?: number
stallMs?: number
log?: (message: string, data: Record<string, unknown>) => void
now?: () => number
}) {
const limit = input.limit ?? requestQueueLimit
const stallMs = input.stallMs ?? requestStallMs
// Call the browser fetch unbound; `input.fetch(...)` would make `this` the options object.
const base = input.fetch
const now = input.now ?? Date.now
const log = input.log ?? ((message, data) => console.warn(`[server-request-queue] ${message}`, data))
const inflight = new Set<Entry>()
const waiting: Array<{ entry: Entry; start: () => void }> = []
let warned = -Infinity
let watcher: ReturnType<typeof setTimeout> | undefined
const describe = (entry: Entry) => ({ method: entry.method, url: entry.url, ms: now() - entry.at })
// Debug exports include the console, so list what the server is busy with while requests wait.
const watch = () => {
watcher = undefined
const oldest = waiting[0]?.entry
if (!oldest) return
if (now() - oldest.at >= stallMs && now() - warned >= 10_000) {
warned = now()
log("server thrashing detected", {
limit,
inflight: [...inflight].map(describe),
queued: waiting.map((item) => describe(item.entry)),
})
}
watcher = setTimeout(watch, stallMs)
}
const release = (entry: Entry) => {
inflight.delete(entry)
waiting.shift()?.start()
}
const acquire = (entry: Entry) =>
new Promise<void>((resolve) => {
const start = () => {
entry.at = now()
inflight.add(entry)
resolve()
}
if (inflight.size < limit) return start()
waiting.push({ entry, start })
watcher ??= setTimeout(watch, stallMs)
})
const fetch: typeof globalThis.fetch = Object.assign(
async (resource: RequestInfo | URL, init?: RequestInit) => {
const request = new Request(resource, init)
// The event stream is long-lived; never count it against the request budget.
if (new URL(request.url).pathname === "/api/event") return base(request)
const entry = { method: request.method, url: request.url, at: now() }
await acquire(entry)
if (request.signal.aborted) {
release(entry)
throw request.signal.reason ?? new DOMException("The operation was aborted.", "AbortError")
}
return base(request).finally(() => release(entry))
},
// Bun's fetch type carries preconnect; the browser never calls it.
{ preconnect: () => {} },
)
return {
fetch,
inflight: () => inflight.size,
queued: () => waiting.length,
}
}
+27 -4
View File
@@ -20,6 +20,8 @@ import { toggleMcp } from "./global-sync/mcp"
import { createConnectionSync } from "./server-sync/connection"
import { usePlatform } from "@/runtime/platform/platform"
import type { Data } from "@opencode-ai/client/solid"
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
@@ -79,6 +81,17 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const worktrees = createWorktreeInventory({
scope: serverSDK.scope,
queryClient,
api: () => serverSDK.api.worktree,
updated: (directory, items) =>
setGlobalStore("project", (projects) =>
projects.map((project) =>
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
),
),
})
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
@@ -197,17 +210,27 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
function applyProjectUpdate(update: Parameters<typeof updateProjectInfo>[1]) {
setGlobalStore("project", (projects) =>
projects.map((project) => (project.id === update.id ? updateProjectInfo(project, update) : project)),
projects.map((project) =>
project.id === update.id
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
: project,
),
)
}
const unsub = serverSDK.event.listen((event) => {
connection.handleEvent({ type: event.type })
if (event.type === "project.updated") applyProjectUpdate(event.data)
if (event.type === "worktree.updated") {
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
if (root) void worktrees.refresh(root)
void bootstrap.refetch()
return
}
if (!event.location) {
if (event.type === "config.updated" || event.type === "agent.updated" || event.type === "worktree.updated")
bootstrap.refetch()
if (event.type === "config.updated" || event.type === "agent.updated") bootstrap.refetch()
return
}
@@ -216,7 +239,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
if (!children.children[key]) return
children.mark(key)
if (event.type === "config.updated" || event.type === "agent.updated") queue.push(key)
if (event.type === "worktree.updated") void bootstrap.refetch()
})
onCleanup(unsub)
@@ -261,6 +283,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
// bootstrap,
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
worktrees,
mcp: {
toggle: async (directory: string, name: string) => {
const key = directoryKey(directory)
@@ -3,11 +3,9 @@ import { useLocation } from "@solidjs/router"
import { ComposerEditor } from "@/composer/editor/editor"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerEditor } from "@/composer/editor/interaction"
import { useLanguage } from "@/runtime/i18n/language"
import type { PendingSession } from "@/shell/tabs/tabs"
export function PreparingComposer(props: { pending: PendingSession }) {
const language = useLanguage()
const location = useLocation()
let element: HTMLElement | undefined
const editor = createComposerEditor({
@@ -20,7 +18,6 @@ export function PreparingComposer(props: { pending: PendingSession }) {
},
view: {
draftOnly: true,
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
+1 -1
View File
@@ -64,7 +64,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
provider: { all: providers.all(), default: providers.default(), connected: [] },
}}
>
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
<div data-component="session-preparing" data-workspace-session class="min-h-0 flex-1 overflow-y-auto">
<SessionIdentityHeader sessionID={props.sessionID} />
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
<SessionUserMessage
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
when={props.showProjectIcon}
fallback={
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
</span>
}
>
@@ -185,7 +185,7 @@ function WorkspaceMoveAction(props: {
: "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"
}
>
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
<Icon 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
@@ -257,10 +257,7 @@ export function SessionSummaryPanel(props: {
gutter={props.mobile ? 4 : -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`}
>
<Icon
name={props.local ? "monitor" : "outline-worktree"}
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
/>
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
{location()}
</span>
@@ -7,6 +7,7 @@ import type { Project } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useSettingsDialog } from "@/settings/command"
import { pathKey } from "@/workspaces/path-key"
import { showToast } from "@/shell/notifications/toast"
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
@@ -27,6 +28,7 @@ export function SessionWorkspaceMenu(props: {
const language = useLanguage()
const serverSDK = useServerSDK()
const data = useData()
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 || data.session.status(props.sessionID) === "running"
@@ -38,8 +40,7 @@ export function SessionWorkspaceMenu(props: {
if (!open) return
const sdk = serverSDK
void sdk.api.worktree
.refresh({ projectID: props.project.id })
.then(() => sdk.api.worktree.list({ projectID: props.project.id }))
.list({ location: { directory: props.directory } })
.then((items) =>
setDirectories(
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
@@ -98,13 +99,13 @@ export function SessionWorkspaceMenu(props: {
</Menu.Item>
</Show>
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="plus" />
<Icon name="workspace-new" />
{language.t("workspace.new")}
</Menu.Item>
<Show when={workspaces().length > 0}>
<Menu.Sub gutter={0} overlap overflowPadding={8}>
<Menu.SubTrigger>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</Menu.SubTrigger>
<Menu.Portal>
@@ -112,7 +113,7 @@ export function SessionWorkspaceMenu(props: {
<For each={workspaces()}>
{(workspace) => (
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="outline-worktree" />
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</Menu.Item>
)}
@@ -122,6 +123,10 @@ export function SessionWorkspaceMenu(props: {
</Menu.Sub>
</Show>
</Menu.Group>
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
<Menu.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
@@ -58,6 +58,7 @@ export const SettingsProviders: Component<{
(provider) =>
provider.id !== "opencode" || Object.values(provider.models).some((model) => model.cost.input > 0),
)
.toSorted((a, b) => Number(b.id === "opencode-go") - Number(a.id === "opencode-go"))
})
const popular = createMemo(() => {
+13 -101
View File
@@ -1029,92 +1029,25 @@
display: flex;
flex-direction: column;
gap: 0;
padding: 16px;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
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-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
padding: 0;
background-color: transparent;
box-shadow: none;
}
.settings-workspaces-row-motion {
display: grid;
grid-template-rows: minmax(0, 1fr);
opacity: 1;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-row-motion[data-removing="true"] {
grid-template-rows: minmax(0, 0fr);
opacity: 0;
}
.settings-workspaces-row {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 12px;
overflow: hidden;
transition:
padding-bottom 150ms ease-out,
margin-bottom 150ms ease-out,
border-color 120ms ease-out;
}
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
.settings-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
> .settings-workspaces-row {
padding-bottom: 0;
margin-bottom: 0;
border-color: transparent;
}
.settings-workspaces-empty-motion {
display: grid;
min-height: 0;
grid-template-rows: minmax(0, 0fr);
opacity: 0;
overflow: hidden;
pointer-events: none;
transition:
grid-template-rows 150ms ease-out,
opacity 120ms ease-out;
}
.settings-workspaces-empty-motion[data-visible="true"] {
grid-template-rows: minmax(0, 1fr);
opacity: 1;
pointer-events: auto;
}
.settings-workspaces-empty-motion > .settings-workspaces-empty {
min-height: 0;
overflow: hidden;
}
@media (prefers-reduced-motion: reduce) {
.settings-workspaces-row-motion {
transition: none;
}
.settings-workspaces-row,
.settings-workspaces-empty-motion {
transition: none;
}
}
.settings-workspaces-row-header {
display: flex;
min-width: 0;
@@ -1155,7 +1088,7 @@
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 440;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
@@ -1165,11 +1098,6 @@
cursor: default;
}
.settings-workspaces-path-name {
color: var(--v2-text-text-base);
font-weight: 530;
}
.settings-workspaces-meta {
font-size: 13px;
font-weight: 440;
@@ -1177,10 +1105,6 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-meta-project {
color: var(--v2-text-text-muted);
}
.settings-workspaces-active,
.settings-workspaces-more {
flex-shrink: 0;
@@ -1190,16 +1114,11 @@
color: var(--v2-text-text-faint);
}
.settings-workspaces-active {
font-size: 13px;
line-height: var(--line-height-compact);
}
.settings-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px;
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
@@ -1210,8 +1129,7 @@
align-items: center;
justify-content: space-between;
gap: 12px;
padding-block: 10px;
padding-inline: 12px 16px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
@@ -1231,28 +1149,22 @@
.settings-workspaces-session-time {
flex-shrink: 0;
font-size: 13px;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty {
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 4px;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
.settings-workspaces-empty-title {
color: var(--v2-text-text-base);
font-weight: 530;
}
@media (max-width: 639px) {
.settings-workspaces-header {
padding: 24px 20px 20px;
+4 -21
View File
@@ -1,5 +1,4 @@
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { Menu } from "@opencode-ai/ui/menu"
@@ -36,7 +35,7 @@ const sections = [
[
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
],
[
{ value: "providers", icon: "providers", label: "settings.providers.title" },
@@ -55,7 +54,6 @@ export const SettingsScreen: Component = () => {
const servers = useServers()
const tabs = useTabs()
const global = useGlobal()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
let root: HTMLDivElement | undefined
onMount(() => {
@@ -148,14 +146,7 @@ export const SettingsScreen: Component = () => {
</Show>
<For each={group}>
{(section) => (
<Menu.RadioItem
value={section.value}
closeOnSelect
onSelect={() => {
if (section.value === "workspaces")
setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Menu.RadioItem value={section.value} closeOnSelect>
<Icon name={section.icon} />
{language.t(section.label)}
</Menu.RadioItem>
@@ -181,12 +172,7 @@ export const SettingsScreen: Component = () => {
<div class="flex flex-col gap-1 w-full">
<For each={group}>
{(section) => (
<Tabs.Trigger
value={section.value}
onClick={() => {
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
}}
>
<Tabs.Trigger value={section.value}>
<Icon name={section.icon} />
{language.t(section.label)}
</Tabs.Trigger>
@@ -222,10 +208,7 @@ export const SettingsScreen: Component = () => {
</Tabs.Content>
<SettingsServerScope directory={directory()}>
<Tabs.Content value="workspaces" class="settings-panel">
<SettingsWorkspaces
activeDirectory={directory()}
resetProjectFilter={() => state.worktreeFilterReset}
/>
<SettingsWorkspaces activeDirectory={directory()} />
</Tabs.Content>
<Tabs.Content value="providers" class="settings-panel">
<SettingsProviders directory={directory()} onBack={showProviders} />
@@ -1,8 +1,6 @@
import type { Component } from "solid-js"
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Key } from "@solid-primitives/keyed"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { useQuery } from "@tanstack/solid-query"
import { Button } from "@opencode-ai/ui/button"
@@ -48,7 +46,7 @@ type Workspace = {
project: Project
}
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (props) => {
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
const serverSDK = useServerSDK()
@@ -58,12 +56,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
const [store, setStore] = createStore({
project: "all",
transaction: undefined as "confirm" | "running" | undefined,
deleting: [] as string[],
removing: [] as string[],
})
createEffect(() => {
props.resetProjectFilter()
setStore("project", "all")
})
const projectQuery = useQuery(() => ({
@@ -73,7 +65,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ projectID: project.id })
.list({ location: { directory: project.canonical } })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
@@ -119,7 +111,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
] as const,
queryFn: () => loadSessions(workspaceDirectories()),
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
placeholderData: (previous) => previous,
refetchOnMount: "always",
}))
const sessionsByWorkspace = createMemo(() => {
@@ -132,29 +123,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
)
})
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
const workspacesWithoutSessions = createMemo(() => {
if (sessionQuery.isPending || sessionQuery.isError) return []
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
})
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
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
const project = projectName(workspace.project)
const label = language.plural("settings.workspaces.sessions", count, {
return language.plural("settings.workspaces.sessions", count, {
count,
project,
project: projectName(workspace.project),
})
const start = label.lastIndexOf(project)
if (start < 0) return label
return (
<>
{label.slice(0, start)}
<span class="settings-workspaces-meta-project">{project}</span>
{label.slice(start + project.length)}
</>
)
}
const lastActive = (workspace: Workspace) => {
const updated = workspaceSessions(workspace)[0]?.time.updated
@@ -182,64 +158,54 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
}
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
const messages = [
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
result.dirty ? language.t("workspace.status.dirty") : undefined,
].filter((message): message is string => message !== undefined)
return messages
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
}
const blocked = (result: WorkspaceDeleteInspection) => {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: result.active
? language.t("settings.workspaces.delete.blocked.active")
: inspectionMessages(result)[0],
description: inspectionMessages(result)[0],
})
}
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
const key = String(pathKey(workspace.directory))
setStore("deleting", (items) => [...items, key])
try {
const preflight = await inspect(workspace, context)
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
projectID: workspace.project.id,
directory: workspace.directory,
force,
})
.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
setStore("removing", (items) => [...items, key])
await new Promise((resolve) => setTimeout(resolve, 150))
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, platform, context.sdk.scope)
await projectQuery.refetch()
} finally {
setStore("deleting", (items) => items.filter((item) => item !== key))
setStore("removing", (items) => items.filter((item) => item !== key))
const preflight = await inspect(workspace, context)
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
blocked(preflight.result)
return
}
const removed = await context.sdk.api.worktree
.remove({
location: { directory: workspace.project.worktree },
directory: workspace.directory,
force,
})
.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, platform, context.sdk.scope)
await projectQuery.refetch()
}
let inspectionID = 0
@@ -287,30 +253,13 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
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(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteAll")}
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteAll.warning")}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
const confirmDeleteWithoutSessions = () => {
if (store.transaction || workspacesWithoutSessions().length === 0) return
const context = captureDeleteContext()
const inventory = [...workspacesWithoutSessions()]
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspaces
title={language.t("settings.workspaces.deleteWithoutSessions")}
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
@@ -322,47 +271,44 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
<>
<div class="settings-tab-header settings-workspaces-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<span class="text-11-regular text-v2-text-text-muted">
{language.t("settings.workspaces.description")}
</span>
</div>
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
<InlineServerSelect />
</div>
</div>
<div class="settings-tab-body settings-workspaces">
<Show when={filtered().length > 0}>
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<div class="settings-workspaces-toolbar">
<span class="settings-workspaces-count">
<Show when={!projectQuery.isPending && !projectQuery.isError}>
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
<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" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.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>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
</Show>
</span>
<div class="settings-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<Menu placement="bottom-end" gutter={6}>
<Menu.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" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content>
<For each={projectOptions()}>
{(option) => (
<Menu.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>
</Menu.Item>
)}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
</Show>
<Show when={filtered().length > 0}>
<Menu placement="bottom-end" gutter={4}>
<Menu.Trigger
as={IconButton}
@@ -375,85 +321,78 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
/>
<Menu.Portal>
<Menu.Content>
<Show when={workspacesWithoutSessions().length > 0}>
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
{language.t("settings.workspaces.deleteWithoutSessions")}
</Menu.Item>
<Menu.Separator />
</Show>
<Menu.Item onSelect={confirmDeleteAll}>
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
</Menu.Item>
</Menu.Content>
</Menu.Portal>
</Menu>
</div>
</Show>
</div>
</Show>
</div>
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
<SettingsList>
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
<div class="settings-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={
<div class="settings-workspaces-empty">
<Show
when={!projectQuery.isPending && !projectQuery.isError}
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
>
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
<span>{language.t("settings.workspaces.empty.description")}</span>
</Show>
{language.t(
projectQuery.isPending
? "common.loading"
: projectQuery.isError
? "common.requestFailed"
: "settings.workspaces.empty",
)}
</div>
</div>
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
{(workspace) => {
const linked = () => workspaceSessions(workspace())
const key = () => String(pathKey(workspace().directory))
const deleting = () => store.deleting.includes(key())
return (
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
}
>
<SettingsList>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-workspaces-row">
<div class="settings-workspaces-row-header">
<div class="settings-workspaces-copy">
<div class="settings-workspaces-main">
<WorkspacePath directory={workspace().directory} />
<Tooltip
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
dir="ltr"
aria-label={workspace.directory}
class="settings-workspaces-path"
>
{workspace.directory}
</span>
</Tooltip>
</div>
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-workspaces-row-actions">
<Show
when={deleting()}
fallback={
<>
<Show when={lastActive(workspace())}>
{(value) => (
<Tooltip
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace().directory),
})}
disabled={!!store.transaction}
icon={<Icon name="outline-trash" size="small" />}
onClick={() => confirmDelete(workspace())}
/>
</>
}
>
<span class="settings-workspaces-active">
{language.t("workspace.lifecycle.deleting")}
</span>
<Show when={lastActive(workspace)}>
{(value) => (
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
<span tabIndex={0} class="settings-workspaces-active">
{value()}
</span>
</Tooltip>
)}
</Show>
<IconButton
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}>
@@ -462,7 +401,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
{(session) => (
<div class="settings-workspaces-session">
<span>{sessionLabel(session)}</span>
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
</Show>
</div>
@@ -471,48 +410,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
</div>
</Show>
</div>
</div>
)
}}
</Key>
</SettingsList>
)
}}
</For>
</SettingsList>
</Show>
</div>
</div>
</>
)
}
function WorkspacePath(props: { directory: string }) {
const [truncated, setTruncated] = createSignal(false)
const name = () => getFilename(props.directory)
return (
<Tooltip
value={props.directory}
placement="top-start"
disabled={!truncated()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
tabIndex={truncated() ? 0 : undefined}
dir="ltr"
aria-label={props.directory}
class="settings-workspaces-path"
>
<span>{props.directory.slice(0, -name().length)}</span>
<span class="settings-workspaces-path-name">{name()}</span>
</span>
</Tooltip>
)
}
function DialogDeleteWorkspaces(props: {
title: string
confirmation: string
warning: string
onDelete: () => Promise<void>
}) {
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
@@ -525,12 +434,13 @@ function DialogDeleteWorkspaces(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={props.title}
title={language.t("settings.workspaces.deleteAll")}
description={
<div class="flex flex-col gap-2">
<div>{props.confirmation}</div>
<div>{props.warning}</div>
</div>
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
@@ -539,7 +449,7 @@ function DialogDeleteWorkspaces(props: {
{language.t("common.cancel")}
</Button>
<Button type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.delete.button")}
{language.t("settings.workspaces.deleteAll")}
</Button>
</DialogFooter>
</Dialog>
@@ -562,7 +472,7 @@ function DialogDeleteWorkspace(props: {
staleTime: 0,
}))
const descriptions = () => {
if (status.isPending) return []
if (status.isPending) return [language.t("workspace.status.checking")]
if (status.isError) return [language.t("workspace.status.error")]
if (!status.data) return []
return props.inspectionMessages(status.data.result)
@@ -577,20 +487,18 @@ function DialogDeleteWorkspace(props: {
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
title={language.t("workspace.delete.title")}
description={
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-1">
<span class="text-11-regular text-v2-text-text-faint">
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
</span>
<code class="block w-fit 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>
</div>
<div>{language.t("settings.workspaces.delete.warning")}</div>
<>
{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")}
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
</div>
</>
}
/>
</DialogHeader>
@@ -601,7 +509,7 @@ function DialogDeleteWorkspace(props: {
<Button
type="button"
variant="danger"
disabled={status.isPending || status.isError}
disabled={status.isPending || status.isError || status.data?.result.active}
onClick={remove}
>
{language.t("workspace.delete.button")}
+1 -1
View File
@@ -74,7 +74,7 @@ export default function Layout(props: ParentProps) {
class="-end-2"
direction="horizontal"
size={state.tabsWidth}
min={130}
min={140}
max={520}
onResize={(width) => setState("tabsWidth", width)}
/>
+4 -4
View File
@@ -356,10 +356,10 @@ export function Titlebar(props: {
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
>
<Icon name="grid-plus" />
<Icon name="grid-plus" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("home.title")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("home.toggle")}</bdi>
@@ -654,10 +654,10 @@ export function Titlebar(props: {
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
<Icon name="edit" />
<Icon name="edit" class="shrink-0" />
<span class="min-w-0 truncate">{language.t("command.session.new")}</span>
<span
class="ms-auto shrink-0 whitespace-nowrap text-v2-text-text-faint opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100"
class="ms-auto hidden min-w-0 truncate text-v2-text-text-faint group-hover:block group-focus-visible:block"
aria-hidden="true"
>
<bdi dir="ltr">{command.keybind("tab.new")}</bdi>
+3 -7
View File
@@ -7,30 +7,27 @@ import { createWorktree } from "./create"
describe("worktree creation", () => {
test.each(
[
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo", parent: "/copies/" },
{ name: "clone", directory: "/copies/repo", root: "/copies/repo", canonical: "/copies/repo" },
{
name: "clone subdirectory",
directory: "/copies/repo/packages/app",
root: "/copies/repo",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "linked worktree subdirectory",
directory: "/linked/task/packages/app",
root: "/linked/task",
canonical: "/copies/repo",
parent: "/copies/",
},
{
name: "Windows clone",
directory: "C:\\copies\\repo\\packages\\app",
root: "C:\\copies\\repo",
canonical: "C:\\copies\\repo",
parent: "C:/copies/",
},
].flatMap((input) => [true, false].map((cached) => ({ ...input, cached }))),
)("uses the clone-local main for $name (cached: $cached)", async (input) => {
)("uses the server destination and clone-local main for $name (cached: $cached)", async (input) => {
const project = { id: "proj_clone", directory: input.root, canonical: input.canonical }
const requests: Request[] = []
const api = OpenCode.make({
@@ -66,10 +63,9 @@ describe("worktree creation", () => {
strategy: "git",
from: input.canonical,
branch: "clone-only",
directory: input.parent,
})
expect(requests.find((request) => request.method === "POST")?.url).toBe(
"http://localhost:3000/api/worktree/proj_clone",
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
)
expect(
requests
+1 -3
View File
@@ -1,6 +1,5 @@
import type { LocationGetOutput, OpenCodeClient } from "@opencode-ai/client/promise"
import type { Data } from "@opencode-ai/client/solid"
import { getDirectory } from "@opencode-ai/util/path"
export async function createWorktree(input: {
api: Pick<OpenCodeClient, "location" | "worktree">
@@ -11,11 +10,10 @@ export async function createWorktree(input: {
}) {
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
const created = await input.api.worktree.create({
projectID: project.id,
location: { directory: input.directory },
strategy: "git",
from: project.canonical,
branch: input.branch,
directory: getDirectory(project.canonical),
})
// Populate the client cache before the destination session mounts.
await input.data.location.syncInfo({ directory: created.directory })
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { QueryClient } from "@tanstack/solid-query"
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
import { createWorktreeInventory, withWorktreeInventory, worktreeInventoryKey } from "./inventory"
import { ServerScope } from "@/runtime/server/scope"
import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global-sync/utils"
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
const client = new QueryClient()
const calls: string[] = []
const updates: Array<[string, WorktreeDirectory[]]> = []
const inventory = createWorktreeInventory({
scope: ServerScope.local,
queryClient: client,
api: () => ({
list: (input) => {
const directory = input!.location!.directory!
calls.push(directory)
return list(directory)
},
}),
updated: (directory, items) => updates.push([directory, items]),
})
return { client, calls, updates, inventory }
}
describe("createWorktreeInventory", () => {
test("loads once per project, shares in-flight work, and publishes the result", async () => {
const gate = Promise.withResolvers<void>()
const setupResult = setup(async (directory) => {
await gate.promise
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
})
const first = setupResult.inventory.load("/repo")
const second = setupResult.inventory.load("/repo/")
expect(setupResult.calls).toEqual(["/repo"])
gate.resolve()
expect(await first).toHaveLength(2)
expect(await second).toHaveLength(2)
await setupResult.inventory.load("/repo")
expect(setupResult.calls).toEqual(["/repo"])
expect(setupResult.updates).toEqual([
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
])
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
setupResult.client.clear()
})
test("refreshes only inventories a view already loaded", async () => {
const setupResult = setup(async (directory) => [{ directory }])
await setupResult.inventory.refresh("/never-opened")
expect(setupResult.calls).toEqual([])
await setupResult.inventory.load("/opened")
await setupResult.inventory.refresh("/opened")
expect(setupResult.calls).toEqual(["/opened", "/opened"])
setupResult.client.clear()
})
test("a failed load is not cached and never rejects the caller", async () => {
let fail = true
const setupResult = setup(async (directory) => {
if (fail) throw new Error("Location unavailable")
return [{ directory }]
})
expect(await setupResult.inventory.load("/repo")).toBeUndefined()
expect(setupResult.inventory.cached("/repo")).toBeUndefined()
fail = false
expect(await setupResult.inventory.load("/repo")).toEqual([{ directory: "/repo" }])
expect(setupResult.calls).toEqual(["/repo", "/repo"])
setupResult.client.clear()
})
test("keys are partitioned by server and normalized by path", () => {
const remote = "https://remote.example" as typeof ServerScope.local
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
)
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
})
})
describe("withWorktreeInventory", () => {
const metadata = {
id: "project",
canonical: "/repo",
name: "Before",
time: { created: 1, updated: 1 },
sandboxes: [],
}
test("derives the workspace list from the inventory, excluding the project root", () => {
const worktrees = [
{ directory: "/repo/" },
{ directory: "/repo/feature", strategy: "git" },
{ directory: "/elsewhere" },
]
expect(withWorktreeInventory(normalizeProjectInfo(metadata), worktrees)).toMatchObject({
worktree: "/repo",
sandboxes: ["/repo/feature", "/elsewhere"],
worktrees,
})
})
test("leaves metadata untouched without an inventory and survives metadata updates", () => {
const project = normalizeProjectInfo(metadata)
expect(withWorktreeInventory(project, undefined)).toBe(project)
const cached = [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]
const updated = updateProjectInfo(withWorktreeInventory(project, cached), { ...metadata, name: "After" })
expect(withWorktreeInventory(updated, cached)).toMatchObject({ name: "After", sandboxes: ["/repo/feature"] })
})
})
+58
View File
@@ -0,0 +1,58 @@
import type { QueryClient } from "@tanstack/solid-query"
import type { WorktreeDirectory } from "@opencode-ai/client/promise"
import type { ServerApi } from "@/runtime/server/api"
import type { ServerScope } from "@/runtime/server/scope"
import type { Project } from "@/runtime/server/types"
import { pathKey } from "./path-key"
import { sameDirectory } from "./paths"
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
return [scope, "worktree", pathKey(directory)] as const
}
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
export function withWorktreeInventory(project: Project, worktrees: readonly WorktreeDirectory[] | undefined): Project {
if (!worktrees) return project
return {
...project,
worktrees: [...worktrees],
sandboxes: worktrees
.map((item) => item.directory)
.filter((directory) => !sameDirectory(project.worktree, directory)),
}
}
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
// projects the user is looking at are loaded. Historical projects stay metadata-only.
export function createWorktreeInventory(input: {
scope: ServerScope
queryClient: QueryClient
api: () => Pick<ServerApi["worktree"], "list">
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
}) {
const options = (directory: string) => ({
queryKey: worktreeInventoryKey(input.scope, directory),
queryFn: () =>
input
.api()
.list({ location: { directory } })
.then((items) => {
input.updated(directory, items)
return items
}),
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
staleTime: Infinity,
gcTime: Infinity,
retry: false,
})
return {
cached: (directory: string) =>
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
// Only inventories some view already demanded are refreshed.
refresh: (directory: string) => {
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
},
}
}
+10 -1
View File
@@ -3,7 +3,7 @@ import type { LocationGetOutput, LocationRef } from "@opencode-ai/client/promise
import { retry } from "@opencode-ai/util/retry"
import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js"
import { type LocationContext, useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
export type { LocationContext } from "@/runtime/server/client"
export type WorkspaceLocation = LocationContext & {
@@ -15,6 +15,7 @@ const context = createSimpleContext({
name: "Location",
init: (props: { directory: string | Accessor<string>; workspaceID?: string | Accessor<string | undefined> }) => {
const serverSDK = useServerSDK()
const server = useServer()
const data = useData()
const ref = createMemo(
() => ({
@@ -40,6 +41,14 @@ const context = createSimpleContext({
retryIf: () => !stale,
}).catch(() => undefined)
})
createEffect(() => {
const id = current()?.project.id
if (!id || serverSDK.connection.status() !== "connected") return
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
// Key it by the metadata root so the result merges into the same global project record.
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
if (root) void server.ctx.sync.worktrees.load(root)
})
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
return createMemo<WorkspaceLocation>(() => ({
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option, Queue } from "effect"
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -47,6 +47,7 @@ export default Runtime.handler(Commands, (input) =>
),
)
const updater = yield* Updater.Service
const update = yield* updater.run().pipe(Effect.forkScoped)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -83,11 +84,15 @@ export default Runtime.handler(Commands, (input) =>
update: (update) => runPromise(config.update(update)),
},
updater: {
monitor: (notify, signal) =>
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
updater.monitor((version) => Effect.sync(() => notify(version))),
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
check: (signal) => runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }),
apply: (version) => runPromise(updater.apply(version)),
},
packages: {
+16
View File
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
export type Mode = "default" | "service" | "stdio"
@@ -163,6 +164,21 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
const url = HttpServer.formatAddress(server.address)
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
if (foreground && !environmentPassword) console.log(`server password ${password}`)
yield* Updater.Service.pipe(
Effect.flatMap((updater) =>
Updater.pollUpdates({
check: updater.run().pipe(
Effect.flatMap((result) => {
if (!result) return Effect.void
if (result.type === "available") return server.updateAvailable(result.version)
return server.updated(result.version)
}),
),
}),
),
Effect.provide(Updater.layer),
Effect.forkScoped,
)
return yield* options.mode === "service"
? server.shutdown
: options.mode === "stdio"
+3 -3
View File
@@ -1,5 +1,5 @@
export type Policy = "disable" | "notify"
export type Action = "none" | "notify"
export type Policy = "disable" | "notify" | "auto"
export type Action = "none" | "notify" | "auto"
const maximumComponent = "9007199254740991"
const versionPattern =
@@ -10,7 +10,7 @@ export function action(current: string, latest: string, policy: Policy): Action
const currentVersion = parseReleaseVersion(current)
const latestVersion = parseReleaseVersion(latest)
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
return "notify"
return policy
}
export function parseReleaseVersion(input: string) {
+7 -2
View File
@@ -6,14 +6,14 @@ describe("updater", () => {
test("reads update policy from JSONC", () => {
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
expect(decodePolicy('{ "update": "auto" }')).toBe("notify")
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
})
test("maps the v1 update policy", () => {
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("notify")
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
})
test("reports every available release", () => {
@@ -23,6 +23,11 @@ describe("updater", () => {
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
})
test("automatically installs every available release when enabled", () => {
expect(action("1.2.3", "1.2.4", "auto")).toBe("auto")
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
})
test("skips when updates are disabled", () => {
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
})
+62 -29
View File
@@ -1,7 +1,7 @@
import { Global } from "@opencode-ai/util/global"
import { AppProcess } from "@opencode-ai/util/process"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
import { Context, Duration, Effect, FileSystem, Layer, Schedule } from "effect"
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
@@ -9,28 +9,28 @@ import { action, parseReleaseVersion, type Policy } from "./updater-action"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export interface Interface {
readonly monitor: (notify: (version: string) => Effect.Effect<void>) => Effect.Effect<void>
readonly run: () => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
readonly apply: (version: string) => Effect.Effect<void, Error>
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
}
export const monitorUpdates = Effect.fnUntraced(function* (input: {
readonly inspect: () => Effect.Effect<string | undefined, Error>
readonly notify: (version: string) => Effect.Effect<void>
export const pollUpdates = Effect.fnUntraced(function* (input: {
readonly check: Effect.Effect<unknown>
readonly initialDelay?: Duration.Input
readonly interval?: Duration.Input
}) {
const interval = input.interval ?? "10 minutes"
const initialDelay = input.initialDelay ?? "90 seconds"
const check = Effect.gen(function* () {
const version = yield* input.inspect()
if (version !== undefined) yield* input.notify(version)
}).pipe(Effect.catch((error) => Effect.logWarning("update check failed", { error })))
return yield* check.pipe(Effect.repeat(Schedule.spaced(interval)), Effect.delay(initialDelay))
return yield* input.check.pipe(
Effect.repeat(Schedule.spaced(interval)),
Effect.delay(input.initialDelay ?? "1 minute"),
)
})
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -43,20 +43,20 @@ export function decodePolicy(text: string): Policy | undefined {
if (errors.length || typeof input !== "object" || input === null) return
if ("update" in input) {
const value = input.update
if (value === "disable" || value === "notify") return value
if (value === "auto") return "notify"
if (value === "disable" || value === "notify" || value === "auto") return value
return
}
if (!("autoupdate" in input)) return
if (input.autoupdate === false) return "disable"
if (input.autoupdate === "notify") return "notify"
if (input.autoupdate === true) return "notify"
if (input.autoupdate === true) return "auto"
}
const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const appProcess = yield* AppProcess.Service
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
const installedPackage = yield* Effect.gen(function* () {
const executable = yield* fs.realPath(process.execPath)
@@ -78,7 +78,7 @@ const make = Effect.gen(function* () {
return values.findLast((value) => value !== undefined) ?? "notify"
})
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
const exec = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
return yield* appProcess
.run(ChildProcess.make(command[0], command.slice(1)), {
timeout,
@@ -113,7 +113,7 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
{ concurrency: "unbounded" },
)
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
@@ -121,12 +121,12 @@ const make = Effect.gen(function* () {
const release = Effect.fnUntraced(function* () {
const response = yield* Effect.tryPromise({
try: () =>
try: (signal) =>
fetch(
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
{
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
signal: AbortSignal.timeout(10_000),
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
@@ -168,17 +168,20 @@ const make = Effect.gen(function* () {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
const installer = path.join(directory, "install")
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
}
return yield* run(commands[method], "5 minutes")
return yield* exec(commands[method], "5 minutes")
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
@@ -200,18 +203,19 @@ const make = Effect.gen(function* () {
return undefined
}
const current = yield* Ref.get(installedVersion)
const version = yield* latest()
yield* Effect.logInfo("update check", {
current: OPENCODE_VERSION,
current,
latest: version,
})
const next = action(OPENCODE_VERSION, version, policy)
const next = action(current, version, policy)
if (next === "none") {
yield* Effect.logInfo("update check done", { action: "up-to-date" })
return undefined
}
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
return version
yield* Effect.logInfo("OpenCode update available", { current, latest: version, action: next })
return { policy, version }
})
const install = Effect.fnUntraced(function* (version: string) {
@@ -220,8 +224,10 @@ const make = Effect.gen(function* () {
yield* Effect.logWarning("update skipped: installation method not found")
return false
}
const current = yield* Ref.get(installedVersion)
yield* upgrade(detected, version)
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
yield* Ref.set(installedVersion, version)
yield* Effect.logInfo("updated OpenCode", { from: current, to: version, method: detected })
return true
})
@@ -229,9 +235,36 @@ const make = Effect.gen(function* () {
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
})
const monitor = (notify: (version: string) => Effect.Effect<void>) => monitorUpdates({ inspect, notify })
const check = Effect.fn("cli.updater.check")(function* () {
if (OPENCODE_LOCAL)
return {
type: "unavailable" as const,
message: "This build runs from a source checkout. Use an installed OpenCode release to check for updates.",
}
const version = yield* latest()
if (!parseReleaseVersion(version)) return yield* Effect.fail(new Error(`Invalid version: ${version}`))
const current = yield* Ref.get(installedVersion)
if (action(current, version, "auto") === "none") {
// An earlier check may have installed the update while this client is still running.
return action(OPENCODE_VERSION, current, "auto") === "none"
? undefined
: { type: "installed" as const, version: current }
}
return { type: "available" as const, version }
})
return Service.of({ monitor, apply, method, latest, upgrade })
const run = Effect.fn("cli.updater.run")(
function* () {
const result = yield* inspect()
if (!result) return undefined
if (result.policy === "notify") return { type: "available" as const, version: result.version }
if (!(yield* install(result.version))) return yield* Effect.fail(new Error("Installation method not found"))
return { type: "installed" as const, version: result.version }
},
Effect.catch((error) => Effect.logWarning("update check failed", { error }).pipe(Effect.as(undefined))),
)
return Service.of({ run, check, apply, method, latest, upgrade })
})
export const layer = Layer.effect(Service, make)
+2 -1
View File
@@ -12,7 +12,8 @@ await Effect.runPromise(
process.argv.slice(2),
).pipe(
Effect.provideService(Updater.Service, {
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
run: () => Effect.die("Manual upgrades must not check for automatic updates"),
check: () => Effect.die("Manual upgrades must not check for TUI updates"),
apply: () => Effect.die("Manual upgrades must not apply TUI updates"),
method: () =>
Effect.sync(() => {
-40
View File
@@ -1,40 +0,0 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("checks after 90 seconds and every 10 minutes after that", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed("2.0.0"),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("89 seconds")
expect(yield* Queue.size(updates)).toBe(0)
yield* TestClock.adjust("1 second")
expect(yield* Queue.take(updates)).toBe("2.0.0")
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
expect(yield* Queue.take(updates)).toBe("2.0.0")
}),
)
it.effect("does not notify when no update is available", () =>
Effect.gen(function* () {
const updates = yield* Queue.unbounded<string>()
yield* Updater.monitorUpdates({
inspect: () => Effect.succeed(undefined),
notify: (version) => Queue.offer(updates, version).pipe(Effect.asVoid),
}).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(updates)).toBe(0)
}),
)
+24
View File
@@ -0,0 +1,24 @@
import { expect } from "bun:test"
import { Effect, Layer, Queue } from "effect"
import { TestClock } from "effect/testing"
import { testEffect } from "../../core/test/lib/effect"
import { Updater } from "../src/services/updater"
const it = testEffect(Layer.empty)
it.effect("polls after 1 minute and every 10 minutes after that", () =>
Effect.gen(function* () {
const checks = yield* Queue.unbounded<void>()
yield* Updater.pollUpdates({ check: Queue.offer(checks, undefined).pipe(Effect.asVoid) }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("59 seconds")
expect(yield* Queue.size(checks)).toBe(0)
yield* TestClock.adjust("1 second")
yield* Queue.take(checks)
yield* Effect.yieldNow
yield* TestClock.adjust("10 minutes")
yield* Queue.take(checks)
}),
)
+13 -9
View File
@@ -1931,33 +1931,37 @@ export interface ReferenceApi<E = never> {
readonly list: ReferenceListOperation<E>
}
export type WorktreeListInput = { readonly projectID: Project.ID }
export type WorktreeListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeListOutput = Worktree.List
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
export type WorktreeCreateInput = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly strategy?: Worktree.StrategyID | undefined
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory: AbsolutePath
readonly directory?: AbsolutePath | undefined
readonly name?: string | undefined
}
export type WorktreeCreateOutput = Worktree.Info
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
export type WorktreeRemoveInput = {
readonly projectID: Project.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly directory: AbsolutePath
readonly force: boolean
}
export type WorktreeRemoveOutput = void
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
export type WorktreeRefreshInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type WorktreeRefreshOutput = void
export type WorktreeRefreshOperation<E = never> = (
input: WorktreeRefreshInput,
input?: WorktreeRefreshInput,
) => Effect.Effect<WorktreeRefreshOutput, E>
export interface WorktreeApi<E = never> {
+12 -12
View File
@@ -1451,21 +1451,21 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
preserveEffect<WorktreeListOutput>()(
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
query: { location: input?.["location"] },
payload: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1473,14 +1473,14 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
preserveEffect<WorktreeRemoveOutput>()(
raw["worktree.remove"]({
params: { projectID: input["projectID"] },
query: { location: input["location"] },
payload: { directory: input["directory"], force: input["force"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
preserveEffect<WorktreeRefreshOutput>()(
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
+1
View File
@@ -20,6 +20,7 @@ export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export type WorktreeApi = Client["worktree"]
export interface CatalogApi {
readonly provider: ProviderApi
+16 -12
View File
@@ -1968,28 +1968,30 @@ export function make(options: ClientOptions) {
),
},
worktree: {
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
path: `/api/worktree`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
path: `/api/worktree`,
query: { location: input?.["location"] },
body: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
strategy: input?.["strategy"],
from: input?.["from"],
branch: input?.["branch"],
directory: input?.["directory"],
name: input?.["name"],
},
successStatus: 200,
declaredStatuses: [400, 401],
@@ -2001,7 +2003,8 @@ export function make(options: ClientOptions) {
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
path: `/api/worktree`,
query: { location: input["location"] },
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -2009,11 +2012,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
path: `/api/worktree/refresh`,
query: { location: input?.["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+32 -17
View File
@@ -430,6 +430,8 @@ export type WebSearchProvider = { id: string; name: string }
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
export type ConfigWorktree = { directory: string }
export type ProviderRequest = {
settings: ProviderSettings
headers: { [x: string]: string }
@@ -1892,7 +1894,7 @@ export type ConfigEntry =
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
update?: "disable" | "notify"
update?: "disable" | "notify" | "auto"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
@@ -1993,6 +1995,7 @@ export type ConfigEntry =
}
websearch?: false | { provider: "random" | (string & {}) }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
worktree?: ConfigWorktree
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
@@ -6077,45 +6080,51 @@ export type ReferenceListOutput = {
data: Array<ReferenceInfo>
}
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeListInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeListOutput = WorktreeList
export type WorktreeCreateInput = {
readonly projectID: { readonly projectID: string }["projectID"]
readonly strategy: {
readonly strategy: string
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
readonly strategy?: {
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["strategy"]
readonly from?: {
readonly strategy: string
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["from"]
readonly branch?: {
readonly strategy: string
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["branch"]
readonly directory: {
readonly strategy: string
readonly directory?: {
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["directory"]
readonly name?: {
readonly strategy: string
readonly strategy?: string
readonly from?: string
readonly branch?: string
readonly directory: string
readonly directory?: string
readonly name?: string
}["name"]
}
@@ -6123,14 +6132,20 @@ export type WorktreeCreateInput = {
export type WorktreeCreateOutput = WorktreeInfo
export type WorktreeRemoveInput = {
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 WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
export type WorktreeRefreshInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type WorktreeRefreshOutput = void
+10 -9
View File
@@ -1032,16 +1032,17 @@ export function createData(config: CreateDataInput) {
if (store.session.info[event.data.sessionID]) {
setStore("session", "info", event.data.sessionID, "revert", undefined)
}
// The projector also deletes inbox items enqueued at or after the boundary without a cancel event.
setStore(
"session",
"pending",
event.data.sessionID,
(store.session.pending[event.data.sessionID] ?? []).filter((item) => item.id < event.data.to),
)
// Inbox ordering is server-owned; IDs do not encode delivery order.
result.session.pending.invalidate(event.data.sessionID)
if (store.session.pending[event.data.sessionID]?.length)
refresh(() => result.session.pending.sync(event.data.sessionID))
message.update(event.data.sessionID, (draft, index) => {
const position = draft.findIndex((item) => item.id >= event.data.to)
if (position === -1) return
const position = draft.findIndex((item) => item.id === event.data.to)
if (position === -1) {
result.session.message.invalidate(event.data.sessionID)
refresh(() => result.session.message.sync(event.data.sessionID))
return
}
for (const item of draft.splice(position)) index.delete(item.id)
})
return
+40 -9
View File
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
)
})
test("worktree methods use the global project contract", async () => {
test("all worktree operations use location-based routes without a project parameter", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -350,27 +350,25 @@ test("worktree methods use the global project contract", async () => {
},
})
expect(await client.worktree.list({ projectID: "proj_test" })).toEqual([{ directory: "/tmp/project" }])
expect(await client.worktree.list()).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,
})
await client.worktree.refresh({ projectID: "proj_test" })
await client.worktree.refresh()
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test"],
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
["GET", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree"],
["DELETE", "http://localhost:3000/api/worktree"],
["POST", "http://localhost:3000/api/worktree/refresh"],
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
@@ -380,6 +378,39 @@ test("worktree methods use the global project contract", async () => {
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
})
test("worktree operations send the configuration location separately from their payload", async () => {
const requests: Request[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
const request = new Request(input, init)
requests.push(request)
if (request.method === "GET") return Response.json([{ directory: "/configured/task", strategy: "git" }])
if (request.method === "DELETE" || new URL(request.url).pathname.endsWith("/refresh"))
return new Response(null, { status: 204 })
return Response.json({ directory: "/configured/task" })
},
})
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
directory: "/configured/task",
})
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[0]?.json()).toEqual({ name: "task" })
await client.worktree.remove({
location: { directory: "/repo/nested" },
directory: "/configured/task",
force: true,
})
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
{ directory: "/configured/task", strategy: "git" },
])
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
})
test("workspace.destroy returns the transition result", async () => {
let request: Request | undefined
const client = OpenCode.make({
@@ -98,7 +98,7 @@ ${body.phone ? `${body.phone}<br>` : ""}`.trim()
return false
}),
AWS.sendEmail({
to: "contact@anoma.ly",
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
+147 -6
View File
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "be60f352-8da1-40e1-8d70-dc41121cfbc5",
"prevIds": ["3fb67508-0196-4bae-b2bd-c08ece7583fd"],
"id": "685a01ff-9b49-4789-a8a6-f1b5779a866c",
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
"ddl": [
{
"name": "account_state",
@@ -72,6 +72,10 @@
"name": "session_v2",
"entityType": "tables"
},
{
"name": "timeline",
"entityType": "tables"
},
{
"name": "workspace",
"entityType": "tables"
@@ -940,6 +944,16 @@
"entityType": "columns",
"table": "session_message"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "timeline_id",
"entityType": "columns",
"table": "session_message"
},
{
"type": "text",
"notNull": true,
@@ -1070,6 +1084,16 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "timeline_id",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": true,
@@ -1420,6 +1444,36 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "timeline"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "base_id",
"entityType": "columns",
"table": "timeline"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "base_seq",
"entityType": "columns",
"table": "timeline"
},
{
"type": "text",
"notNull": false,
@@ -1588,13 +1642,13 @@
"table": "session_inbox"
},
{
"columns": ["session_id"],
"tableTo": "session_v2",
"columns": ["timeline_id"],
"tableTo": "timeline",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "CASCADE",
"nameExplicit": false,
"name": "fk_session_message_session_id_session_v2_id_fk",
"name": "fk_session_message_timeline_id_timeline_id_fk",
"entityType": "fks",
"table": "session_message"
},
@@ -1609,6 +1663,17 @@
"entityType": "fks",
"table": "session_pending"
},
{
"columns": ["timeline_id"],
"tableTo": "timeline",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "NO ACTION",
"nameExplicit": false,
"name": "fk_session_v2_timeline_id_timeline_id_fk",
"entityType": "fks",
"table": "session_v2"
},
{
"columns": ["project_id"],
"tableTo": "project",
@@ -1620,6 +1685,17 @@
"entityType": "fks",
"table": "session_v2"
},
{
"columns": ["base_id"],
"tableTo": "timeline",
"columnsTo": ["id"],
"onUpdate": "NO ACTION",
"onDelete": "NO ACTION",
"nameExplicit": false,
"name": "fk_timeline_base_id_timeline_id_fk",
"entityType": "fks",
"table": "timeline"
},
{
"columns": ["project_id"],
"tableTo": "project",
@@ -1757,6 +1833,13 @@
"table": "session_v2",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "timeline_pk",
"table": "timeline",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
@@ -1877,13 +1960,71 @@
"isExpression": false
}
],
"isUnique": true,
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_message_session_seq_idx",
"entityType": "indexes",
"table": "session_message"
},
{
"columns": [
{
"value": "timeline_id",
"isExpression": false
},
{
"value": "seq",
"isExpression": false
}
],
"isUnique": true,
"where": null,
"origin": "manual",
"name": "session_message_timeline_seq_idx",
"entityType": "indexes",
"table": "session_message"
},
{
"columns": [
{
"value": "timeline_id",
"isExpression": false
},
{
"value": "type",
"isExpression": false
},
{
"value": "seq",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "session_message_timeline_type_seq_idx",
"entityType": "indexes",
"table": "session_message"
},
{
"columns": [
{
"value": "timeline_id",
"isExpression": false
},
{
"value": "seq",
"isExpression": false
}
],
"isUnique": false,
"where": "((\"session_message\".\"type\" = 'assistant' AND json_extract(\"session_message\".\"data\", '$.time.completed') IS NULL)\n OR (\"session_message\".\"type\" IN ('shell', 'compaction') AND json_extract(\"session_message\".\"data\", '$.status') = 'running'))",
"origin": "manual",
"name": "session_message_unsettled_idx",
"entityType": "indexes",
"table": "session_message"
},
{
"columns": [
{
+1 -1
View File
@@ -12,7 +12,7 @@ ${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalo
## Search
Use \`search\` to discover exact paths and signatures for additional tools:
Call \`search(...)\` to discover exact paths and signatures for additional tools:
- ${searchSignature}` : ""}
+1 -1
View File
@@ -61,7 +61,7 @@ export type Inventory = {
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
+2 -3
View File
@@ -74,9 +74,7 @@ export function normalize(input: unknown): Result {
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
: undefined
const nativeUpdate = own(input, "update")
? input.update === "auto"
? "notify"
: decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
? decodeEncoded(Info.fields.update, input.update, ["update"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
@@ -211,6 +209,7 @@ export function normalize(input: unknown): Result {
media: Info.fields.media,
tool_output: Info.fields.tool_output,
websearch: Info.fields.websearch,
worktree: Info.fields.worktree,
warming: Info.fields.warming,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
@@ -0,0 +1,35 @@
export * as ConfigWorktreePlugin from "./worktree.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Effect } from "effect"
import path from "path"
import { Config } from "../../config.js"
import { Global } from "@opencode-ai/util/global"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
import { Worktree } from "../../worktree.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.worktree",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const location = yield* Location.Service
const global = yield* Global.Service
const worktrees = yield* Worktree.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
yield* worktrees.transform((editor) => {
for (const entry of loaded.entries) {
if (entry.type !== "document" || !entry.info.worktree) continue
const directory = entry.info.worktree.directory
editor.configure({
directory: AbsolutePath.make(
directory.startsWith("~/")
? path.join(global.home, directory.slice(2))
: path.resolve(entry.path ? path.dirname(entry.path) : location.directory, directory),
),
})
}
})
}),
})
+2
View File
@@ -45,6 +45,7 @@ import m42 from "./migration/20260812181746_session_inbox.js"
import m43 from "./migration/20260812213948_worktree.js"
import m44 from "./migration/20260819222447_session_viewed_state.js"
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
import m46 from "./migration/20260906003536_timeline.js"
export const migrations = [
m00,
@@ -93,4 +94,5 @@ export const migrations = [
m43,
m44,
m45,
m46,
] satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,126 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"
// Each existing Session, including copied forks, starts with an independent root.
// Rebuilds preserve IDs, payloads, sequence numbers and timestamps. Session dependents
// are evacuated inside this transaction so engines with mandatory FK cascades are safe.
const migration: DatabaseMigration.Migration = {
id: "20260906003536_timeline",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`CREATE TABLE \`timeline\` (
\`id\` text PRIMARY KEY,
\`base_id\` text,
\`base_seq\` integer,
CONSTRAINT \`fk_timeline_base_id_timeline_id_fk\` FOREIGN KEY (\`base_id\`) REFERENCES \`timeline\`(\`id\`)
);`)
// Use the small Session table to map existing messages to independent roots.
// The replacement table below enforces NOT NULL on the backfilled IDs.
yield* tx.run(`ALTER TABLE session_v2 ADD timeline_id text`)
yield* tx.run(`UPDATE session_v2 SET timeline_id = 'tml_' || lower(hex(randomblob(16)))`)
yield* tx.run(`INSERT INTO timeline (id) SELECT timeline_id FROM session_v2`)
yield* tx.run(`CREATE TABLE \`__new_session_message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`timeline_id\` text NOT NULL,
\`type\` text NOT NULL,
\`seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
);`)
yield* tx.run(
`INSERT INTO \`__new_session_message\` (\`id\`, \`session_id\`, \`timeline_id\`, \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\`) SELECT \`id\`, \`session_id\`, (SELECT timeline_id FROM session_v2 WHERE session_v2.id = session_message.session_id), \`type\`, \`seq\`, \`time_created\`, \`time_updated\`, \`data\` FROM \`session_message\``,
)
yield* tx.run(`DROP TABLE \`session_message\``)
yield* tx.run(`ALTER TABLE \`__new_session_message\` RENAME TO \`session_message\``)
yield* tx.run(`CREATE TABLE __timeline_instruction_entry AS SELECT * FROM instruction_entry`)
yield* tx.run(`DELETE FROM instruction_entry`)
yield* tx.run(`CREATE TABLE __timeline_instruction_state AS SELECT * FROM instruction_state`)
yield* tx.run(`DELETE FROM instruction_state`)
yield* tx.run(`CREATE TABLE __timeline_session_inbox AS SELECT * FROM session_inbox`)
yield* tx.run(`DELETE FROM session_inbox`)
yield* tx.run(`CREATE TABLE __timeline_session_pending AS SELECT * FROM session_pending`)
yield* tx.run(`DELETE FROM session_pending`)
yield* tx.run(`CREATE TABLE \`__new_session_v2\` (
\`id\` text PRIMARY KEY,
\`timeline_id\` text NOT NULL,
\`project_id\` text NOT NULL,
\`workspace_id\` text,
\`parent_id\` text,
\`fork_session_id\` text,
\`fork_boundary\` text,
\`slug\` text NOT NULL,
\`directory\` text NOT NULL,
\`path\` text,
\`title\` text,
\`version\` text NOT NULL,
\`share_url\` text,
\`summary_additions\` integer,
\`summary_deletions\` integer,
\`summary_files\` integer,
\`summary_diffs\` text,
\`metadata\` text,
\`cost\` real DEFAULT 0 NOT NULL,
\`tokens_input\` integer DEFAULT 0 NOT NULL,
\`tokens_output\` integer DEFAULT 0 NOT NULL,
\`tokens_reasoning\` integer DEFAULT 0 NOT NULL,
\`tokens_cache_read\` integer DEFAULT 0 NOT NULL,
\`tokens_cache_write\` integer DEFAULT 0 NOT NULL,
\`revert\` text,
\`permission\` text,
\`agent\` text,
\`model\` text,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`time_idle\` integer,
\`time_viewed\` integer,
\`idle_outcome\` text,
\`time_compacting\` integer,
\`time_archived\` integer,
\`time_suspended\` integer,
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_v2_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`),
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);`)
yield* tx.run(
`INSERT INTO \`__new_session_v2\` (\`id\`, \`timeline_id\`, \`project_id\`, \`workspace_id\`, \`parent_id\`, \`fork_session_id\`, \`fork_boundary\`, \`slug\`, \`directory\`, \`path\`, \`title\`, \`version\`, \`share_url\`, \`summary_additions\`, \`summary_deletions\`, \`summary_files\`, \`summary_diffs\`, \`metadata\`, \`cost\`, \`tokens_input\`, \`tokens_output\`, \`tokens_reasoning\`, \`tokens_cache_read\`, \`tokens_cache_write\`, \`revert\`, \`permission\`, \`agent\`, \`model\`, \`time_created\`, \`time_updated\`, \`time_idle\`, \`time_viewed\`, \`idle_outcome\`, \`time_compacting\`, \`time_archived\`, \`time_suspended\`, \`resume_attempts\`) SELECT \`id\`, \`timeline_id\`, \`project_id\`, \`workspace_id\`, \`parent_id\`, \`fork_session_id\`, \`fork_boundary\`, \`slug\`, \`directory\`, \`path\`, \`title\`, \`version\`, \`share_url\`, \`summary_additions\`, \`summary_deletions\`, \`summary_files\`, \`summary_diffs\`, \`metadata\`, \`cost\`, \`tokens_input\`, \`tokens_output\`, \`tokens_reasoning\`, \`tokens_cache_read\`, \`tokens_cache_write\`, \`revert\`, \`permission\`, \`agent\`, \`model\`, \`time_created\`, \`time_updated\`, \`time_idle\`, \`time_viewed\`, \`idle_outcome\`, \`time_compacting\`, \`time_archived\`, \`time_suspended\`, \`resume_attempts\` FROM \`session_v2\``,
)
yield* tx.run(`DROP TABLE \`session_v2\``)
yield* tx.run(`ALTER TABLE \`__new_session_v2\` RENAME TO \`session_v2\``)
yield* tx.run(`INSERT INTO instruction_entry SELECT * FROM __timeline_instruction_entry`)
yield* tx.run(`DROP TABLE __timeline_instruction_entry`)
yield* tx.run(`INSERT INTO instruction_state SELECT * FROM __timeline_instruction_state`)
yield* tx.run(`DROP TABLE __timeline_instruction_state`)
yield* tx.run(`INSERT INTO session_inbox SELECT * FROM __timeline_session_inbox`)
yield* tx.run(`DROP TABLE __timeline_session_inbox`)
yield* tx.run(`INSERT INTO session_pending SELECT * FROM __timeline_session_pending`)
yield* tx.run(`DROP TABLE __timeline_session_pending`)
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_timeline_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_timeline_type_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(`CREATE INDEX \`session_message_unsettled_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`) WHERE (("session_message"."type" = 'assistant' AND json_extract("session_message"."data", '$.time.completed') IS NULL)
OR ("session_message"."type" IN ('shell', 'compaction') AND json_extract("session_message"."data", '$.status') = 'running'));`)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
)
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`)
yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`)
yield* tx.run(
`CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`,
)
})
},
}
export default migration
+21 -2
View File
@@ -158,12 +158,13 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
CREATE TABLE \`session_message\` (
\`id\` text PRIMARY KEY,
\`session_id\` text NOT NULL,
\`timeline_id\` text NOT NULL,
\`type\` text NOT NULL,
\`seq\` integer NOT NULL,
\`time_created\` integer NOT NULL,
\`time_updated\` integer NOT NULL,
\`data\` text NOT NULL,
CONSTRAINT \`fk_session_message_session_id_session_v2_id_fk\` FOREIGN KEY (\`session_id\`) REFERENCES \`session_v2\`(\`id\`) ON DELETE CASCADE
CONSTRAINT \`fk_session_message_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
@@ -181,6 +182,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run(`
CREATE TABLE \`session_v2\` (
\`id\` text PRIMARY KEY,
\`timeline_id\` text NOT NULL,
\`project_id\` text NOT NULL,
\`workspace_id\` text,
\`parent_id\` text,
@@ -216,9 +218,18 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
\`time_archived\` integer,
\`time_suspended\` integer,
\`resume_attempts\` integer DEFAULT 0 NOT NULL,
CONSTRAINT \`fk_session_v2_timeline_id_timeline_id_fk\` FOREIGN KEY (\`timeline_id\`) REFERENCES \`timeline\`(\`id\`),
CONSTRAINT \`fk_session_v2_project_id_project_id_fk\` FOREIGN KEY (\`project_id\`) REFERENCES \`project\`(\`id\`) ON DELETE CASCADE
);
`)
yield* tx.run(`
CREATE TABLE \`timeline\` (
\`id\` text PRIMARY KEY,
\`base_id\` text,
\`base_seq\` integer,
CONSTRAINT \`fk_timeline_base_id_timeline_id_fk\` FOREIGN KEY (\`base_id\`) REFERENCES \`timeline\`(\`id\`)
);
`)
yield* tx.run(`
CREATE TABLE \`workspace\` (
\`id\` text PRIMARY KEY,
@@ -249,9 +260,17 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
yield* tx.run(
`CREATE UNIQUE INDEX \`session_inbox_session_enqueued_seq_idx\` ON \`session_inbox\` (\`session_id\`,\`enqueued_seq\`);`,
)
yield* tx.run(`CREATE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
yield* tx.run(
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
`CREATE UNIQUE INDEX \`session_message_timeline_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`);`,
)
yield* tx.run(
`CREATE INDEX \`session_message_timeline_type_seq_idx\` ON \`session_message\` (\`timeline_id\`,\`type\`,\`seq\`);`,
)
yield* tx.run(`
CREATE INDEX \`session_message_unsettled_idx\` ON \`session_message\` (\`timeline_id\`,\`seq\`) WHERE (("session_message"."type" = 'assistant' AND json_extract("session_message"."data", '$.time.completed') IS NULL)
OR ("session_message"."type" IN ('shell', 'compaction') AND json_extract("session_message"."data", '$.status') = 'running'));
`)
yield* tx.run(
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
)
+13 -305
View File
@@ -10,10 +10,9 @@ import { KVTable } from "../kv/sql.js"
import { EventSequenceTable } from "../event/sql.js"
import { eq, sql } from "drizzle-orm"
import { Global } from "@opencode-ai/util/global"
import { existsSync } from "node:fs"
import path from "node:path"
import type { Database as SQLiteDatabase } from "bun:sqlite"
import { Project } from "@opencode-ai/schema/project"
import { Timeline } from "../session/timeline.js"
export type SourceMessage = {
readonly id: string
@@ -88,10 +87,6 @@ type RunResult = {
readonly status: "completed"
}
type Options = {
readonly nextDatabasePath?: string
}
type MigrationState = { readonly phase: "sessions"; readonly cursor?: string } | { readonly phase: "completed" }
type RuntimeState =
@@ -99,118 +94,6 @@ type RuntimeState =
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
type NextProject = {
readonly id: string
readonly worktree: string
readonly vcs: string | null
readonly name: string | null
readonly icon_url: string | null
readonly icon_url_override: string | null
readonly icon_color: string | null
readonly time_created: number
readonly time_updated: number
readonly time_initialized: number | null
readonly sandboxes: string
readonly commands: string | null
}
type NextColumns<A> = Record<keyof A, "required" | "nullable" | { readonly fallback: keyof A & string }>
const NEXT_PROJECT_COLUMNS = {
id: "required",
worktree: "required",
vcs: "nullable",
name: "nullable",
icon_url: "nullable",
icon_url_override: { fallback: "icon_url" },
icon_color: "nullable",
time_created: "required",
time_updated: "required",
time_initialized: "nullable",
sandboxes: "required",
commands: "nullable",
} satisfies NextColumns<NextProject>
type NextSession = {
readonly id: string
readonly project_id: string
readonly workspace_id: string | null
readonly parent_id: string | null
readonly fork_session_id: string | null
readonly fork_boundary: string | null
readonly slug: string
readonly directory: string
readonly path: string | null
readonly title: string | null
readonly version: string
readonly share_url: string | null
readonly summary_additions: number | null
readonly summary_deletions: number | null
readonly summary_files: number | null
readonly summary_diffs: string | null
readonly metadata: string | null
readonly cost: number
readonly tokens_input: number
readonly tokens_output: number
readonly tokens_reasoning: number
readonly tokens_cache_read: number
readonly tokens_cache_write: number
readonly revert: string | null
readonly permission: string | null
readonly agent: string | null
readonly model: string | null
readonly time_created: number
readonly time_updated: number
readonly time_compacting: number | null
readonly time_archived: number | null
readonly time_suspended: number | null
}
const NEXT_SESSION_COLUMNS = {
id: "required",
project_id: "required",
workspace_id: "nullable",
parent_id: "nullable",
fork_session_id: "nullable",
fork_boundary: "nullable",
slug: "required",
directory: "required",
path: "nullable",
title: "nullable",
version: "required",
share_url: "nullable",
summary_additions: "nullable",
summary_deletions: "nullable",
summary_files: "nullable",
summary_diffs: "nullable",
metadata: "nullable",
cost: "required",
tokens_input: "required",
tokens_output: "required",
tokens_reasoning: "required",
tokens_cache_read: "required",
tokens_cache_write: "required",
revert: "nullable",
permission: "nullable",
agent: "nullable",
model: "nullable",
time_created: "required",
time_updated: "required",
time_compacting: "nullable",
time_archived: "nullable",
time_suspended: "nullable",
} satisfies NextColumns<NextSession>
type NextMessage = {
readonly id: string
readonly session_id: string
readonly type: string
readonly seq: number
readonly time_created: number
readonly time_updated: number
readonly data: string
}
const lock = Semaphore.makeUnsafe(1)
const MIGRATION_STATE_KEY = "migration.v1-v2"
const EVENT_DELETE_BATCH_SIZE = 1_000
@@ -513,7 +396,7 @@ function updateProgress(progress: Progress) {
if (runtimeState.status === "running") runtimeState = { status: "running", progress }
}
export function run(options: Options = {}): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
export function run(): Effect.Effect<RunResult, never, Database.Service | Global.Service> {
return lock.withPermit(
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -546,7 +429,6 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
}),
)
.pipe(Effect.orDie)
const sourceTotal = yield* countNextSessions(nextPath(options, global.data))
const legacyTotal = (yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session`))?.value ?? 0
const cursor = state?.phase === "sessions" ? state.cursor : undefined
const migrated =
@@ -554,12 +436,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
? ((yield* db.get<{ value: number }>(sql`SELECT COUNT(*) AS value FROM session WHERE id >= ${cursor}`))
?.value ?? 0)
: 0
const denominator = sourceTotal + legacyTotal
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator })
yield* importNextDatabase(db, nextPath(options, global.data), (completed) => {
updateProgress({ label: "Migrating sessions", numerator: migrated + completed, denominator })
})
updateProgress({ label: "Migrating sessions", numerator: migrated + sourceTotal, denominator })
updateProgress({ label: "Migrating sessions", numerator: migrated, denominator: legacyTotal })
const projects = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM project`)).map((project) => project.id),
)
@@ -589,15 +466,21 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
sessionID: nextID.id,
projectID: nextID.project_id,
})
const existing = yield* tx
.select({ timelineID: SessionTable.timeline_id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(nextID.id)))
.get()
const timelineID = existing?.timelineID ?? (yield* Timeline.create(tx))
yield* tx.run(sql`
INSERT OR IGNORE INTO session_v2 (
id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
id, timeline_id, project_id, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
)
SELECT
id, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
id, ${timelineID}, ${projectID}, workspace_id, parent_id, slug, directory, path, title, version, share_url,
summary_additions, summary_deletions, summary_files, summary_diffs, metadata, cost,
tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write,
revert, permission, agent, model, time_created, time_updated, time_compacting, time_archived
@@ -627,6 +510,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
timeline_id: next.timeline_id,
type: message.type,
seq: message.seq,
time_created: message.time_created,
@@ -657,7 +541,7 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
progress: {
label: "Migrating sessions",
numerator: (runtimeState.progress.numerator ?? 0) + 1,
denominator,
denominator: legacyTotal,
},
}
yield* Effect.yieldNow
@@ -681,182 +565,6 @@ export function run(options: Options = {}): Effect.Effect<RunResult, never, Data
)
}
function nextPath(options: Options, data: string) {
if (options.nextDatabasePath) return options.nextDatabasePath
if (process.env.OPENCODE_DB === ":memory:") return undefined
return path.join(data, "opencode-next.db")
}
function openNextDatabase(sourcePath: string) {
return Effect.acquireRelease(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
return new sqlite.Database(sourcePath, { readonly: true, strict: true })
}),
(source) => Effect.sync(() => source.close()),
)
}
function countNextSessions(sourcePath: string | undefined) {
if (!sourcePath || !existsSync(sourcePath)) return Effect.succeed(0)
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) return 0
return source.query<{ value: number }, []>("SELECT COUNT(*) AS value FROM session").get()?.value ?? 0
}),
)
}
function importNextDatabase(
db: Database.Interface["db"],
sourcePath: string | undefined,
onProgress: (completed: number) => void,
): Effect.Effect<void, unknown> {
if (!sourcePath || !existsSync(sourcePath)) return Effect.void
return Effect.scoped(
Effect.gen(function* () {
const source = yield* openNextDatabase(sourcePath)
if (!isNextDatabase(source)) {
yield* Effect.logWarning("Skipped incompatible opencode-next.db", { path: sourcePath })
return
}
source.run("BEGIN")
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
if (source.inTransaction) source.run("ROLLBACK")
}),
)
const projects = new Map(
selectNextRows<NextProject>(source, "project", NEXT_PROJECT_COLUMNS).map((project) => [project.id, project]),
)
const sessions = selectNextRows<NextSession>(source, "session", NEXT_SESSION_COLUMNS)
for (const [index, session] of sessions.entries()) {
const project = projects.get(session.project_id)
const projectID = project ? session.project_id : Project.ID.global
if (!project) {
yield* Effect.logWarning("Reassigned previous V2 session with missing project", {
sessionID: session.id,
projectID: session.project_id,
})
}
const messages = source
.query<
NextMessage,
[string]
>("SELECT id, session_id, type, seq, time_created, time_updated, data FROM session_message WHERE session_id = ? ORDER BY seq")
.all(session.id)
yield* db
.transaction((tx) =>
Effect.gen(function* () {
if (project)
yield* tx.run(sql`
INSERT OR IGNORE INTO project (
id, worktree, vcs, name, icon_url, icon_url_override, icon_color,
time_created, time_updated, time_initialized, sandboxes, commands
) VALUES (
${project.id}, ${project.worktree}, ${project.vcs}, ${project.name}, ${project.icon_url},
${project.icon_url_override}, ${project.icon_color}, ${project.time_created}, ${project.time_updated},
${project.time_initialized}, ${project.sandboxes}, ${project.commands}
)
`)
const existing = yield* tx
.select({ id: SessionTable.id })
.from(SessionTable)
.where(eq(SessionTable.id, SessionSchema.ID.make(session.id)))
.get()
if (existing) return
yield* tx.run(sql`
INSERT INTO session_v2 (
id, project_id, workspace_id, parent_id, fork_session_id, fork_boundary, slug, directory,
path, title, version, share_url, summary_additions, summary_deletions, summary_files,
summary_diffs, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read,
tokens_cache_write, revert, permission, agent, model, time_created, time_updated, time_compacting,
time_archived, time_suspended
) VALUES (
${session.id}, ${projectID}, ${session.workspace_id}, ${session.parent_id},
${session.fork_session_id}, ${session.fork_boundary}, ${session.slug}, ${session.directory},
${session.path}, ${session.title}, ${session.version}, ${session.share_url},
${session.summary_additions}, ${session.summary_deletions}, ${session.summary_files},
${session.summary_diffs}, ${session.metadata}, ${session.cost}, ${session.tokens_input},
${session.tokens_output}, ${session.tokens_reasoning}, ${session.tokens_cache_read},
${session.tokens_cache_write}, ${session.revert}, ${session.permission}, ${session.agent},
${session.model}, ${session.time_created}, ${session.time_updated}, ${session.time_compacting},
${session.time_archived}, ${session.time_suspended}
)
`)
yield* Effect.forEach(messages, (message) =>
tx
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(message.id),
session_id: SessionSchema.ID.make(message.session_id),
type: message.type as SessionMessage.Type,
seq: message.seq,
time_created: message.time_created,
time_updated: message.time_updated,
data: sql`${message.data}`,
})
.run(),
)
yield* tx
.insert(EventSequenceTable)
.values({ aggregate_id: session.id, seq: messages.at(-1)?.seq ?? -1 })
.onConflictDoUpdate({
target: EventSequenceTable.aggregate_id,
set: { seq: messages.at(-1)?.seq ?? -1, owner_id: null },
})
.run()
}),
)
.pipe(Effect.orDie)
onProgress(index + 1)
yield* Effect.yieldNow
}
source.run("COMMIT")
}),
)
}
function isNextDatabase(source: SQLiteDatabase) {
const tables = new Set(
source
.query<{ name: string }, []>("SELECT name FROM sqlite_master WHERE type = 'table'")
.all()
.map((table) => table.name),
)
return tables.has("project") && tables.has("session") && tables.has("session_message")
}
function selectNextRows<A>(source: SQLiteDatabase, table: "project" | "session", definition: NextColumns<A>) {
const columns = new Set(
source
.query<{ name: string }, [string]>("SELECT name FROM pragma_table_info(?)")
.all(table)
.map((column) => column.name),
)
const missing = Object.entries(definition)
.filter(([column, strategy]) => strategy === "required" && !columns.has(column))
.map(([column]) => column)
if (missing.length)
throw new Error(`Incompatible opencode-next.db: ${table} is missing required columns: ${missing.join(", ")}`)
const projection = Object.entries(definition).map(([column, strategy]) => {
if (columns.has(column)) return `"${column}"`
if (
typeof strategy === "object" &&
strategy !== null &&
"fallback" in strategy &&
typeof strategy.fallback === "string" &&
columns.has(strategy.fallback)
)
return `"${strategy.fallback}" AS "${column}"`
return `NULL AS "${column}"`
})
return source
.query<A, []>(`SELECT ${projection.join(", ")} FROM "${table}"${table === "session" ? ' ORDER BY "id" DESC' : ""}`)
.all()
}
function row(
source: SourceMessage,
message: {
+3 -1
View File
@@ -25,6 +25,7 @@ import { Plugin } from "./plugin.js"
import { PluginHooks } from "./plugin/hooks.js"
import { InstancePlugins } from "./plugin/instance.js"
import { PluginSupervisor } from "./plugin/supervisor.js"
import { WorktreeRefresh } from "./worktree/refresh.js"
import { Worktree } from "./worktree.js"
import { Pty } from "./pty.js"
import { Shell } from "./shell.js"
@@ -71,7 +72,8 @@ const nodes = [
PluginHooks.node,
InstancePlugins.node,
PluginSupervisor.node,
Worktree.refreshNode,
WorktreeRefresh.node,
Worktree.node,
FileSystemSearch.node,
FileSystem.node,
ShellSelect.node,
+40
View File
@@ -30,6 +30,7 @@ import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { Worktree } from "../worktree.js"
import { Generate } from "../generate.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "./hooks.js"
@@ -69,6 +70,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
const sessions = yield* Session.Service
const persistentPty = yield* PersistentPty.Service
const locations = yield* LocationServiceMap.Service
const worktrees = yield* Worktree.Service
const locationInfo = () =>
new Location.Info({
directory: location.directory,
@@ -88,6 +90,24 @@ export const make = Effect.fn("PluginHost.make")(function* (
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
const atWorktree = <A, E>(
ref: Location.Ref | undefined,
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
) => {
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
if (!ref || isCurrentLocation(ref)) return run(worktrees)
return Effect.gen(function* () {
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
const plugins = yield* Plugin.Service
const target = yield* Worktree.Service
yield* plugins.awaitActivation
return yield* run(target)
}).pipe(Effect.provide(locations.get(ref)))
}
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
const listAgents = Effect.fn("PluginHost.listAgents")((ref: Location.Ref) =>
Effect.gen(function* () {
const location = yield* Location.Service
@@ -462,6 +482,25 @@ export const make = Effect.fn("PluginHost.make")(function* (
})
}),
},
worktree: {
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
reload: worktrees.reload,
transform: (callback) =>
worktrees.transform((editor) =>
callback({
add: (definition) =>
editor.add({
id: Worktree.StrategyID.make(definition.id),
create: (input) => definition.create(input).pipe(Effect.flatMap(decodeWorktree)),
remove: (input) => definition.remove(input),
list: (directory) => definition.list(directory).pipe(Effect.flatMap(decodeWorktrees)),
}),
}),
),
},
session: {
hook: (name, callback, options) => hooks.register("session", name, callback, options),
create: (input) =>
@@ -510,6 +549,7 @@ export const requirements = LayerNode.group([
Tool.node,
Vcs.node,
WebSearch.node,
Worktree.node,
Generate.node,
Permission.node,
PluginHooks.node,
+5
View File
@@ -27,6 +27,8 @@ import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
import { ConfigSkillPlugin } from "../config/plugin/skill.js"
import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
import { Worktree } from "../worktree.js"
import { Bus } from "../bus.js"
import { Environment } from "../environment/index.js"
import { FileMutation } from "../file-mutation.js"
@@ -135,6 +137,7 @@ const services = [
ToolOutput.Service,
Watcher.Service,
WellKnown.Service,
Worktree.Service,
] as const
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
@@ -183,6 +186,7 @@ export const requirements = LayerNode.group([
ToolOutput.node,
Watcher.node,
WellKnown.node,
Worktree.node,
])
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
@@ -232,6 +236,7 @@ const post = [
ConfigSkillPlugin.Plugin,
ConfigProviderPlugin.Plugin,
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
+4 -2
View File
@@ -274,10 +274,12 @@ export const OpenAIPlugin = define({
return
}
const apiID = draft.modelID ?? draft.id
const match = apiID.match(/^gpt-(\d+\.\d+)/)
const match = apiID.match(/^gpt-(\d+)(?:\.(\d+))?/)
const major = Number(match?.[1])
const minor = Number(match?.[2] ?? 0)
if (
!codexAllowed.has(apiID) &&
(codexDisallowed.has(apiID) || !match || Number.parseFloat(match[1]) <= 5.4)
(codexDisallowed.has(apiID) || !match || !(major > 5 || (major === 5 && minor > 4)))
) {
draft.enabled = false
return
+7 -14
View File
@@ -4,7 +4,7 @@ export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream } from "effect"
import { LLMClient } from "@opencode-ai/ai"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, desc, eq } from "drizzle-orm"
import { eq } from "drizzle-orm"
import { Project } from "./project.js"
import { Model } from "@opencode-ai/schema/model"
import { Location } from "./location.js"
@@ -63,6 +63,7 @@ import { Job } from "./job.js"
import type { Command } from "./command.js"
import { SessionEnvironment } from "./session/environment.js"
import { InstructionEntry } from "./session/instruction-entry.js"
import { Timeline } from "./session/timeline.js"
// get project -> project.locations
//
@@ -292,19 +293,11 @@ const layer = Layer.effect(
}),
fork: Effect.fn("Session.fork")(function* (input) {
const parent = yield* result.get(input.sessionID)
const boundary = yield* db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const ranges = yield* Timeline.forSession(db, input.sessionID)
const [boundary] = yield* Timeline.rows(db, ranges, {
where: input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
limit: 1,
})
if (!boundary && input.boundary.type === "before")
return yield* new MessageNotFoundError({
sessionID: input.sessionID,
+26 -36
View File
@@ -1,4 +1,4 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { and, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { MessageDecodeError } from "./error.js"
@@ -7,26 +7,25 @@ import { SessionSchema } from "./schema.js"
import { Instructions } from "../instructions/index.js"
import { InstructionState } from "./instruction-state.js"
import { SessionMessageTable } from "./sql.js"
import { Timeline } from "./timeline.js"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
export const latestCompaction = Effect.fnUntraced(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
ranges?: readonly Timeline.Range[],
) {
const [row] = yield* Timeline.rows(db, ranges ?? (yield* Timeline.forSession(db, sessionID)), {
where: and(
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
),
limit: 1,
})
return row
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
@@ -41,19 +40,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
)
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
const compaction = yield* latestCompaction(db, sessionID)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const ranges = yield* Timeline.forSession(db, sessionID)
const compaction = yield* latestCompaction(db, sessionID, ranges)
const rows = yield* Timeline.rows(db, ranges, {
where: compaction ? gte(SessionMessageTable.seq, compaction.seq) : undefined,
order: "asc",
})
return yield* Effect.forEach(rows, (row) =>
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
)
@@ -112,13 +104,11 @@ export const firstUserMessage = Effect.fn("SessionHistory.firstUserMessage")(fun
db: DatabaseService,
sessionID: SessionSchema.ID,
) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "user")))
.orderBy(asc(SessionMessageTable.seq))
.get()
.pipe(Effect.orDie)
const [row] = yield* Timeline.rows(db, yield* Timeline.forSession(db, sessionID), {
where: eq(SessionMessageTable.type, "user"),
order: "asc",
limit: 1,
})
if (!row) return undefined
const message = yield* decodeMessageRow(row).pipe(Effect.orElseSucceed(() => undefined))
return message?.type === "user" ? message : undefined
+26 -15
View File
@@ -409,19 +409,17 @@ export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function*
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
const next = (delivery: Delivery) =>
db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, delivery)))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const steer = yield* next("steer")
const steer = (yield* pendingSteers(db, sessionID))[0]
if (steer) return fromRow(steer)
if (promotable !== "input") return undefined
const queued = yield* next("queue")
const queued = yield* db
.select()
.from(SessionInboxTable)
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "queue")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.limit(1)
.get()
.pipe(Effect.orDie)
return queued ? fromRow(queued) : undefined
})
@@ -490,9 +488,10 @@ const publish = Effect.fn("SessionInbox.publish")(function* (
})
/**
* Promotes pending input into visible messages and returns the promoted count.
* Steers always go first; only the "input" scope may fall through to one queued
* input, and it then collects steers that arrived during promotion.
* Promotes pending input into visible messages and returns the promoted count,
* or undefined when the runner must first handle a pending control.
* Steered compaction takes priority over pending prompts, without crossing a move.
* Only the "input" scope may fall through to one queued input.
*/
export const promote = Effect.fn("SessionInbox.promote")(function* (
db: DatabaseService,
@@ -506,6 +505,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
const steers = yield* pendingSteers(db, sessionID)
if (steers.length > 0 || scope === "steer") {
const control = steers.findIndex((row) => row.type === "compaction" || row.type === "move")
if (control === 0) return undefined
return yield* publish(db, bus, sessionID, control === -1 ? steers : steers.slice(0, control))
}
@@ -518,6 +518,7 @@ export const promote = Effect.fn("SessionInbox.promote")(function* (
.get()
.pipe(Effect.orDie)
if (!queued) return 0
if (queued.type === "compaction" || queued.type === "move") return undefined
const promoted = yield* publish(db, bus, sessionID, [queued])
const arrivedSteers = yield* pendingSteers(db, sessionID)
const control = arrivedSteers.findIndex((row) => row.type === "compaction" || row.type === "move")
@@ -536,4 +537,14 @@ const pendingSteers = (db: DatabaseService, sessionID: SessionSchema.ID) =>
.where(and(eq(SessionInboxTable.session_id, sessionID), eq(SessionInboxTable.delivery, "steer")))
.orderBy(asc(SessionInboxTable.enqueued_seq))
.all()
.pipe(Effect.orDie)
.pipe(
Effect.orDie,
Effect.map((rows) => {
// A move changes the context's Location: never pull compaction across it.
// Within that boundary, compact before promoting even earlier steers so
// their text stays verbatim after the checkpoint, not inside its summary.
const control = rows.findIndex((row) => row.type === "compaction" || row.type === "move")
if (control > 0 && rows[control].type === "compaction") rows.unshift(...rows.splice(control, 1))
return rows
}),
)
+48 -65
View File
@@ -1,6 +1,6 @@
export * as SessionProjector from "./projector.js"
import { and, asc, desc, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from "drizzle-orm"
import { and, desc, eq, gt, gte, inArray, isNull, lt, lte, not, or, sql } from "drizzle-orm"
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
import path from "path"
import { Database } from "../database/database.js"
@@ -14,7 +14,7 @@ import { SessionMessageUpdater } from "./message-updater.js"
import { SessionInbox } from "./inbox.js"
import { Workspace } from "@opencode-ai/schema/workspace"
import { InstructionState } from "./instruction-state.js"
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
import { SessionInboxTable, SessionMessageTable, SessionTable, unsettled } from "./sql.js"
import { InstructionEntry } from "./instruction-entry.js"
import { Slug } from "../util/slug.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
@@ -24,6 +24,7 @@ import { Project } from "@opencode-ai/schema/project"
import { AbsolutePath, RelativePath } from "../schema.js"
import type { SessionSchema } from "./schema.js"
import { ProjectTable } from "../project/sql.js"
import { Timeline } from "./timeline.js"
type DatabaseService = Database.Interface["db"]
type MessageEvent = Exclude<
@@ -115,39 +116,30 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
.pipe(Effect.orDie)
if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`))
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
eq(SessionMessageTable.id, event.data.boundary.messageID),
),
)
.where(eq(SessionMessageTable.id, event.data.boundary.messageID))
.get()
.pipe(Effect.orDie)
if (!boundary)
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`))
const copied = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
event.data.boundary.type === "before"
? lt(SessionMessageTable.seq, boundary.seq)
: lte(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
const ranges = yield* Timeline.ranges(db, boundary.timeline_id)
const end = boundary.seq + (event.data.boundary.type === "through" ? 1 : 0)
const [copied] = yield* Timeline.rows(db, ranges, { where: lt(SessionMessageTable.seq, end), limit: 1 })
const copiedSeq = copied?.seq
const [active] = yield* Timeline.rows(db, ranges, {
where: and(lt(SessionMessageTable.seq, end), unsettled(SessionMessageTable)),
order: "asc",
limit: 1,
})
const base = yield* Timeline.prefix(db, ranges, active?.seq ?? end)
const timelineID = yield* Timeline.create(db, base)
const stored = yield* db
.insert(SessionTable)
.values({
id: event.data.sessionID,
timeline_id: timelineID,
parent_id: null,
fork_session_id: event.data.parentID,
fork_boundary: event.data.boundary,
@@ -179,26 +171,19 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
if (event.data.instructionEntries)
yield* InstructionEntry.initialize(db, event.data.sessionID, event.data.instructionEntries, event.created)
let cursor = -1
while (copiedSeq !== undefined) {
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.parentID),
gt(SessionMessageTable.seq, cursor),
lt(SessionMessageTable.seq, copiedSeq + 1),
// Terminal events for active projections stay on the parent, so forks copy only settled history.
sql`${SessionMessageTable.type} != 'assistant' or json_extract(${SessionMessageTable.data}, '$.time.completed') is not null`,
sql`${SessionMessageTable.type} != 'shell' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
sql`${SessionMessageTable.type} != 'compaction' or json_extract(${SessionMessageTable.data}, '$.status') != 'running'`,
),
)
.orderBy(asc(SessionMessageTable.seq))
.limit(ForkBatchSize)
.all()
.pipe(Effect.orDie)
// Active forks only copy the settled suffix after the first omitted message;
// the preceding immutable prefix is shared.
let cursor = (active?.seq ?? end) - 1
while (cursor < (copiedSeq ?? -1)) {
const rows = yield* Timeline.rows(db, ranges, {
where: and(
gt(SessionMessageTable.seq, cursor),
lt(SessionMessageTable.seq, end),
not(unsettled(SessionMessageTable)),
),
order: "asc",
limit: ForkBatchSize,
})
if (rows.length === 0) break
yield* db
@@ -207,6 +192,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
rows.map((row) => ({
id: SessionMessage.ID.make(`${SessionMessage.ID.fromEvent(event.id)}_${row.seq}`),
session_id: event.data.sessionID,
timeline_id: timelineID,
type: row.type,
seq: row.seq,
time_created: row.time_created,
@@ -301,7 +287,10 @@ function run(db: DatabaseService, event: MessageEvent) {
.select()
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.type, "assistant")),
and(
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
eq(SessionMessageTable.type, "assistant"),
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
@@ -359,7 +348,7 @@ function run(db: DatabaseService, event: MessageEvent) {
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, event.data.sessionID),
eq(SessionMessageTable.timeline_id, Timeline.current(event.data.sessionID)),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
@@ -390,6 +379,7 @@ function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, me
.values({
id: SessionMessage.ID.make(id),
session_id: event.data.sessionID,
timeline_id: Timeline.current(event.data.sessionID),
type,
seq: event.durable.seq,
time_created: DateTime.toEpochMillis(message.time.created),
@@ -436,10 +426,12 @@ const layer = Layer.effectDiscard(
const db = (yield* Database.Service).db
yield* bus.project(SessionEvent.Created, (event) =>
Effect.gen(function* () {
const timelineID = yield* Timeline.create(db)
const stored = yield* db
.insert(SessionTable)
.values({
id: event.data.sessionID,
timeline_id: timelineID,
project_id: event.data.projectID,
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
parent_id: event.data.parentID,
@@ -539,7 +531,10 @@ const layer = Layer.effectDiscard(
}),
)
yield* bus.project(SessionEvent.Deleted, (event) =>
db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie),
Effect.gen(function* () {
yield* db.delete(SessionTable).where(eq(SessionTable.id, event.data.sessionID)).run().pipe(Effect.orDie)
yield* Timeline.collect(db)
}),
)
yield* bus.project(SessionEvent.AgentSelected, (event) =>
Effect.gen(function* () {
@@ -720,20 +715,14 @@ const layer = Layer.effectDiscard(
)
yield* bus.project(SessionEvent.RevertEvent.Committed, (event) =>
Effect.gen(function* () {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), eq(SessionMessageTable.id, event.data.to)),
)
.get()
.pipe(Effect.orDie)
const boundary = yield* Timeline.find(db, event.data.sessionID, event.data.to)
if (!boundary) return yield* Effect.die(new Error(`Revert boundary message not found: ${event.data.to}`))
const base = yield* Timeline.prefix(db, yield* Timeline.ranges(db, boundary.timeline_id), boundary.seq)
const timelineID = yield* Timeline.create(db, base)
yield* db
.delete(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, event.data.sessionID), gte(SessionMessageTable.seq, boundary.seq)),
)
.update(SessionTable)
.set({ timeline_id: timelineID, revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* db
@@ -746,12 +735,6 @@ const layer = Layer.effectDiscard(
)
.run()
.pipe(Effect.orDie)
yield* db
.update(SessionTable)
.set({ revert: null, time_updated: event.created })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
yield* InstructionState.reset(db, event.data.sessionID)
}),
)
+7 -20
View File
@@ -1,6 +1,6 @@
export * as SessionRevert from "./revert.js"
import { and, asc, eq, gt } from "drizzle-orm"
import { and, eq, gt } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Database } from "../database/database.js"
import { Bus } from "../bus.js"
@@ -12,6 +12,7 @@ import { MessageNotFoundError } from "./error.js"
import { SessionMessage } from "./message.js"
import { SessionSchema } from "./schema.js"
import { SessionMessageTable } from "./sql.js"
import { Timeline } from "./timeline.js"
export { MessageNotFoundError }
@@ -84,26 +85,12 @@ export const commit = Effect.fn("SessionRevert.commit")(function* (bus: Bus.Inte
})
const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["db"], input: BoundaryInput) {
const boundary = yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)))
.get()
.pipe(Effect.orDie)
const boundary = yield* Timeline.find(db, input.sessionID, input.messageID)
if (!boundary) return yield* new MessageNotFoundError(input)
const rows = yield* db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
eq(SessionMessageTable.type, "assistant"),
gt(SessionMessageTable.seq, boundary.seq),
),
)
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
where: and(eq(SessionMessageTable.type, "assistant"), gt(SessionMessageTable.seq, boundary.seq)),
order: "asc",
})
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
+6 -2
View File
@@ -17,6 +17,7 @@ import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
import { SessionMessageTable } from "../sql.js"
import { Timeline } from "../timeline.js"
import { SessionTitle } from "../title.js"
import { DrainResult, Service, type Interface } from "./index.js"
import { Snapshot } from "../../snapshot.js"
@@ -147,7 +148,7 @@ const layer = Layer.effect(
}
if (!force && !continuing && (!pending || (pending.delivery === "queue" && promotable === "steer")))
return DrainResult.Complete()
return yield* restore(
const ready = yield* restore(
Effect.gen(function* () {
const selected = yield* prepareContext(sessionID)
const promoted = yield* SessionInbox.promote(
@@ -156,6 +157,8 @@ const layer = Layer.effect(
sessionID,
entering && !continuing ? promotable : "steer",
)
// A control admitted during context preparation owns this boundary.
if (promoted === undefined) return undefined
if (promoted > 0 && !selected.session.parentID && SessionTitle.isUntitled(selected.session))
yield* FiberMap.run(titles, sessionID, title.generate(sessionID), {
onlyIfMissing: true,
@@ -164,6 +167,7 @@ const layer = Layer.effect(
return { _tag: "Ready" as const, context: yield* context.load(selected) }
}),
)
if (ready) return ready
}
}),
),
@@ -290,7 +294,7 @@ const layer = Layer.effect(
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.session_id, sessionID),
eq(SessionMessageTable.timeline_id, Timeline.current(sessionID)),
eq(SessionMessageTable.type, "compaction"),
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'running'`,
),
+1 -1
View File
@@ -58,7 +58,7 @@ export const make = Effect.fn("Session.make")(function* () {
return session
})
const message = Effect.fn("Session.message")(function* (sessionID: SessionSchema.ID, messageID: SessionMessage.ID) {
const stored = yield* store.message(messageID)
const stored = yield* store.message(messageID, sessionID)
return stored?.sessionID === sessionID ? stored.message : undefined
})
const updateMessage = Effect.fn("Session.updateMessage")(function* (
+39 -6
View File
@@ -1,5 +1,14 @@
import { sqliteTable, text, integer, index, primaryKey, real, uniqueIndex } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"
import {
sqliteTable,
text,
integer,
index,
primaryKey,
real,
uniqueIndex,
type AnySQLiteColumn,
} from "drizzle-orm/sqlite-core"
import { sql, type SQLWrapper } from "drizzle-orm"
import { directoryColumn, pathColumn } from "../database/path.js"
import { ProjectTable } from "../project/sql.js"
import type { SessionMessage } from "./message.js"
@@ -15,14 +24,27 @@ import type { Session } from "@opencode-ai/schema/session"
import type { CompactionPayload, MovePayload, SyntheticPayload, UserPayload } from "@opencode-ai/schema/session-inbox"
import type { RevertV1 } from "@opencode-ai/schema/session-revert"
import type { Schema } from "effect"
import type { Timeline } from "./timeline.js"
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
type SessionMessageData = DistributiveOmit<(typeof SessionMessage.Info)["Encoded"], "type" | "id">
export const TimelineTable = sqliteTable("timeline", {
id: text().$type<Timeline.ID>().primaryKey(),
base_id: text()
.$type<Timeline.ID>()
.references((): AnySQLiteColumn => TimelineTable.id),
base_seq: integer(),
})
export const SessionTable = sqliteTable(
"session_v2",
{
id: text().$type<SessionSchema.ID>().primaryKey(),
timeline_id: text()
.$type<Timeline.ID>()
.notNull()
.references(() => TimelineTable.id),
project_id: text()
.$type<Project.ID>()
.notNull()
@@ -80,23 +102,34 @@ export const SessionMessageTable = sqliteTable(
"session_message",
{
id: text().$type<SessionMessage.ID>().primaryKey(),
session_id: text()
.$type<SessionSchema.ID>()
// Provenance survives deletion of the originating session while forks still reference its history.
session_id: text().$type<SessionSchema.ID>().notNull(),
timeline_id: text()
.$type<Timeline.ID>()
.notNull()
.references(() => SessionTable.id, { onDelete: "cascade" }),
.references(() => TimelineTable.id, { onDelete: "cascade" }),
type: text().$type<SessionMessage.Type>().notNull(),
seq: integer().notNull(),
...Timestamps,
data: text({ mode: "json" }).notNull().$type<SessionMessageData>(),
},
(table) => [
uniqueIndex("session_message_session_seq_idx").on(table.session_id, table.seq),
// A restored Session ID may have retained messages in another timeline.
index("session_message_session_seq_idx").on(table.session_id, table.seq),
uniqueIndex("session_message_timeline_seq_idx").on(table.timeline_id, table.seq),
index("session_message_timeline_type_seq_idx").on(table.timeline_id, table.type, table.seq),
index("session_message_unsettled_idx").on(table.timeline_id, table.seq).where(unsettled(table)),
index("session_message_session_type_seq_idx").on(table.session_id, table.type, table.seq),
index("session_message_session_time_created_id_idx").on(table.session_id, table.time_created, table.id),
index("session_message_time_created_idx").on(table.time_created),
],
)
export function unsettled(table: { type: SQLWrapper; data: SQLWrapper }) {
return sql`((${table.type} = 'assistant' AND json_extract(${table.data}, '$.time.completed') IS NULL)
OR (${table.type} IN ('shell', 'compaction') AND json_extract(${table.data}, '$.status') = 'running'))`
}
export const SessionPendingTable = sqliteTable(
"session_pending",
{
+18 -29
View File
@@ -13,6 +13,7 @@ import { SessionMessage } from "./message.js"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql.js"
import { fromRow } from "./info.js"
import { Timeline } from "./timeline.js"
const ListInputBase = {
workspaceID: Workspace.ID.pipe(Schema.optional),
@@ -56,6 +57,7 @@ export interface Interface {
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
readonly message: (
messageID: SessionMessage.ID,
sessionID?: Session.ID,
) => Effect.Effect<{ readonly sessionID: Session.ID; readonly message: SessionMessage.Info } | undefined>
/**
* Top-level Sessions holding an execution claim. Recoverable background
@@ -140,49 +142,36 @@ const layer = Layer.effect(
const direction = input.cursor?.direction ?? "next"
const requestedOrder = input.order ?? "desc"
const order = direction === "previous" ? (requestedOrder === "asc" ? "desc" : "asc") : requestedOrder
const anchor = input.cursor
? yield* db
.select({ seq: SessionMessageTable.seq })
.from(SessionMessageTable)
.where(
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.cursor.id)),
)
.get()
.pipe(Effect.orDie)
: undefined
const anchor = input.cursor ? yield* Timeline.find(db, input.sessionID, input.cursor.id) : undefined
if (input.cursor && !anchor) return []
const boundary = anchor
? order === "asc"
? gt(SessionMessageTable.seq, anchor.seq)
: lt(SessionMessageTable.seq, anchor.seq)
: undefined
const where = boundary
? and(eq(SessionMessageTable.session_id, input.sessionID), boundary)
: eq(SessionMessageTable.session_id, input.sessionID)
const query = db
.select()
.from(SessionMessageTable)
.where(where)
.orderBy(order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
const rows = yield* (input.limit === undefined ? query.all() : query.limit(input.limit).all()).pipe(
Effect.orDie,
)
const rows = yield* Timeline.rows(db, yield* Timeline.forSession(db, input.sessionID), {
where: boundary,
order,
limit: input.limit,
})
return yield* Effect.forEach(
direction === "previous" ? rows.toReversed() : rows,
SessionHistory.decodeMessageRow,
)
}),
context: Effect.fn("SessionStore.context")((sessionID) => SessionHistory.load(db, sessionID)),
message: Effect.fn("SessionStore.message")(function* (messageID) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
message: Effect.fn("SessionStore.message")(function* (messageID, sessionID) {
const row = sessionID
? yield* Timeline.find(db, sessionID, messageID)
: yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
return row
? {
sessionID: Session.ID.make(row.session_id),
sessionID: sessionID ?? Session.ID.make(row.session_id),
message: yield* SessionHistory.decodeMessageRow(row).pipe(Effect.orDie),
}
: undefined
+123
View File
@@ -0,0 +1,123 @@
export * as Timeline from "./timeline.js"
import { and, asc, desc, eq, lt, sql, type SQL } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { statics } from "@opencode-ai/schema/schema"
import type { Database } from "../database/database.js"
import { SessionMessageTable, SessionTable, TimelineTable } from "./sql.js"
export const ID = Schema.String.check(Schema.isStartsWith("tml_")).pipe(
Schema.brand("Timeline.ID"),
statics((schema) => ({ create: () => schema.make(`tml_${crypto.randomUUID()}`) })),
)
export type ID = typeof ID.Type
type DB = Omit<Database.Interface["db"], "$client">
export type Position = { readonly id: ID; readonly seq: number }
export type Range = { readonly id: ID; readonly end: number | null }
export const create = Effect.fn("Timeline.create")(function* (db: DB, base?: Position) {
const id = ID.create()
yield* db.insert(TimelineTable).values({ id, base_id: base?.id, base_seq: base?.seq }).run().pipe(Effect.orDie)
return id
})
export const current = (sessionID: Session.ID) => sql`(SELECT timeline_id FROM ${SessionTable} WHERE id = ${sessionID})`
/** Resolve ancestry once, then read each physical range using (timeline_id, seq). */
export const ranges = Effect.fn("Timeline.ranges")(function* (db: DB, id: ID) {
return yield* db
.all<Range>(
sql`
WITH RECURSIVE lineage(id, base_id, base_seq, end, depth) AS (
SELECT id, base_id, base_seq, NULL, 0 FROM timeline WHERE id = ${id}
UNION ALL
SELECT base.id, base.base_id, base.base_seq,
CASE WHEN lineage.end IS NULL THEN lineage.base_seq
ELSE min(lineage.end, lineage.base_seq) END,
lineage.depth + 1
FROM timeline AS base JOIN lineage ON base.id = lineage.base_id
)
SELECT id, end FROM lineage ORDER BY depth ASC
`,
)
.pipe(Effect.orDie)
})
export const forSession = Effect.fn("Timeline.forSession")(function* (db: DB, sessionID: Session.ID) {
const session = yield* db
.select({ id: SessionTable.timeline_id })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
return session ? yield* ranges(db, session.id) : []
})
export const includes = (ranges: readonly Range[], row: { timeline_id: ID; seq: number }) =>
ranges.some((range) => range.id === row.timeline_id && (range.end === null || row.seq < range.end))
export const find = Effect.fn("Timeline.find")(function* (db: DB, sessionID: Session.ID, messageID: SessionMessage.ID) {
const row = yield* db
.select()
.from(SessionMessageTable)
.where(eq(SessionMessageTable.id, messageID))
.get()
.pipe(Effect.orDie)
if (!row) return undefined
return includes(yield* forSession(db, sessionID), row) ? row : undefined
})
export const rows = Effect.fn("Timeline.rows")(function* (
db: DB,
ranges: readonly Range[],
input: { readonly where?: SQL; readonly order?: "asc" | "desc"; readonly limit?: number } = {},
) {
const result: (typeof SessionMessageTable.$inferSelect)[] = []
for (const range of input.order === "asc" ? ranges.toReversed() : ranges) {
if (input.limit !== undefined && result.length >= input.limit) break
const query = db
.select()
.from(SessionMessageTable)
.where(
and(
eq(SessionMessageTable.timeline_id, range.id),
range.end === null ? undefined : lt(SessionMessageTable.seq, range.end),
input.where,
),
)
.orderBy(input.order === "asc" ? asc(SessionMessageTable.seq) : desc(SessionMessageTable.seq))
result.push(
...(yield* (input.limit === undefined ? query.all() : query.limit(input.limit - result.length).all()).pipe(
Effect.orDie,
)),
)
}
return result
})
/** Root at the physical owner of the last retained message, skipping empty intermediate timelines. */
export const prefix = Effect.fn("Timeline.prefix")(function* (db: DB, ranges: readonly Range[], end: number) {
const [last] = yield* rows(db, ranges, { where: lt(SessionMessageTable.seq, end), limit: 1 })
return last ? { id: last.timeline_id, seq: last.seq + 1 } : undefined
})
/** Session deletion releases a head; references from surviving histories keep their storage alive. */
export const collect = Effect.fn("Timeline.collect")(function* (db: DB) {
yield* db
.run(
sql`
WITH RECURSIVE retained(id) AS (
SELECT timeline_id FROM session_v2
UNION
SELECT timeline.base_id FROM timeline JOIN retained ON timeline.id = retained.id
WHERE timeline.base_id IS NOT NULL
)
DELETE FROM timeline WHERE id NOT IN (SELECT id FROM retained)
`,
)
.pipe(Effect.orDie)
})
+22 -2
View File
@@ -3,7 +3,7 @@ export * as SessionTransfer from "./transfer.js"
import { SessionTransfer } from "@opencode-ai/schema/session-transfer"
import { Tool } from "@opencode-ai/schema/tool"
import { Skill } from "@opencode-ai/schema/skill"
import { eq } from "drizzle-orm"
import { eq, inArray } from "drizzle-orm"
import { Clock, Context, DateTime, Effect, Layer, Schema } from "effect"
import { map } from "effect/Array"
import path from "path"
@@ -21,6 +21,7 @@ import { SessionEvent } from "./event.js"
import { SessionMessage } from "./message.js"
import { SessionProjector } from "./projector.js"
import { SessionMessageTable, SessionTable } from "./sql.js"
import { Timeline } from "./timeline.js"
export const Data = SessionTransfer.Data
export type Data = SessionTransfer.Data
@@ -74,12 +75,31 @@ const layer = Layer.effect(
const project = yield* projects.resolve(input.location.directory)
yield* upsertProject(db, project).pipe(Effect.orDie)
const importedAt = yield* Clock.currentTimeMillis
// Related exports may share message IDs. Imports materialize independent
// snapshots, so give colliding rows fresh identities instead of stealing ownership.
const ids = input.data.messages.map((message) => message.id)
const batches = Array.from({ length: Math.ceil(ids.length / 500) }, (_, index) =>
ids.slice(index * 500, (index + 1) * 500),
)
const existing = new Set(
(yield* Effect.forEach(batches, (batch) =>
db
.select({ id: SessionMessageTable.id })
.from(SessionMessageTable)
.where(inArray(SessionMessageTable.id, batch))
.all()
.pipe(Effect.orDie),
))
.flat()
.map((row) => row.id),
)
const messages = input.data.messages.filter(isSettled).map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
return {
id: message.id,
id: existing.has(message.id) ? SessionMessage.ID.create() : message.id,
session_id: sessionID,
timeline_id: Timeline.current(sessionID),
type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
+4 -2
View File
@@ -29,9 +29,11 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
update:
info.autoupdate === false
? "disable"
: info.autoupdate === "notify" || info.autoupdate === true
: info.autoupdate === "notify"
? "notify"
: undefined,
: info.autoupdate === true
? "auto"
: undefined,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
+152 -148
View File
@@ -1,12 +1,13 @@
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 { and, asc, desc, eq, isNull, sql } 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 { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Global } from "@opencode-ai/util/global"
import { ProjectSchema } from "./project/schema.js"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
@@ -20,8 +21,10 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode-ai/util/process"
import { ChildProcess } from "effect/unstable/process"
import { State } from "./state.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export { OperationError } from "@opencode-ai/schema/worktree"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
@@ -32,11 +35,6 @@ 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),
@@ -46,16 +44,10 @@ 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 const ListEntry = Worktree.ListEntry
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
@@ -78,9 +70,9 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
{ strategy: StrategyID },
) {}
export class DuplicateStrategyError extends Schema.TaggedError<DuplicateStrategyError>()(
"Worktree.DuplicateStrategyError",
{ strategy: StrategyID },
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
"Worktree.UnsupportedLocationError",
{ directory: AbsolutePath },
) {}
export type Error =
@@ -89,6 +81,8 @@ export type Error =
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| UnsupportedLocationError
| Worktree.OperationError
| AppProcess.AppProcessError
| Git.WorktreeError
@@ -98,67 +92,78 @@ export interface Strategy {
sourceDirectory: AbsolutePath
directory: AbsolutePath
branch?: string
}) => 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>
}) => Effect.Effect<Info, unknown>
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
}
export const Event = Worktree.Event
interface StoredInput {
readonly projectID: ProjectSchema.ID
readonly directory: AbsolutePath
readonly strategy?: string
readonly replace?: boolean
}
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>
export interface Editor {
readonly add: (strategy: Strategy) => void
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
}
export interface Interface extends State.Transformable<Editor> {
readonly list: () => Effect.Effect<List, Error>
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
readonly refresh: () => 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 database = yield* Database.Service
const db = database.db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const location = yield* Location.Service
const global = yield* Global.Service
const projectID = location.project.id
const changed = Effect.fnUntraced(function* (projectID: ProjectSchema.ID, update: boolean) {
const local = location.workspaceID
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
: Effect.void
const gitStrategy = yield* WorktreeGit.make
const state = State.create({
name: "worktree",
initial: () => ({
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
selected: gitStrategy.id,
}),
editor: (value): Editor => ({
configure: (settings) => {
value.directory = settings.directory
},
add: (strategy) => {
value.strategies.delete(strategy.id)
value.strategies.set(strategy.id, strategy)
value.selected = strategy.id
},
}),
})
const changed = Effect.fnUntraced(function* (update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const ops = {
list: Effect.fn("Worktree.list")(function* (projectID: ProjectSchema.ID) {
list: Effect.fnUntraced(function* () {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -168,7 +173,7 @@ const layer = Layer.effect(
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (projectID: ProjectSchema.ID, directory: AbsolutePath) {
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
@@ -177,24 +182,21 @@ const layer = Layer.effect(
.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: (input: StoredInput, tx?: Transaction) =>
(tx ?? db)
.insert(WorktreeTable)
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
.values({
project_id: 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),
set: {
strategy: input.strategy ?? null,
},
// Discovery may claim an unowned row, but never replace another strategy's ownership.
setWhere: input.replace ? undefined : input.strategy ? isNull(WorktreeTable.strategy) : sql`false`,
})
.returning({ directory: WorktreeTable.directory })
.get()
@@ -202,7 +204,7 @@ const layer = Layer.effect(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
remove: (projectID: ProjectSchema.ID, directory: AbsolutePath, tx?: Transaction) =>
remove: (directory: AbsolutePath, tx?: Transaction) =>
(tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
@@ -214,62 +216,57 @@ const layer = Layer.effect(
),
}
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 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 source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
const sourceDirectory = input ?? location.project.directory
const resolved = yield* canonical(fs, sourceDirectory)
if ((yield* ops.find(projectID, resolved)) === undefined)
if ((yield* ops.find(resolved)) === undefined)
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
return resolved
})
const getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
const found = registry.get(id)
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
const found = strategies.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 create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
yield* local
const current = state.get()
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
const directory = input.directory ?? current.directory
const sourceDirectory = yield* source(input.from)
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(input.directory, name))
let worktreeDirectory = AbsolutePath.make(path.join(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}`))
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
}
const result = yield* selected.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
const created = yield* selected
.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
if (result.directory !== (yield* canonical(fs, worktreeDirectory)))
return yield* new InvalidDirectoryError({ directory: result.directory })
yield* changed(
input.projectID,
yield* ops.create({
projectID: input.projectID,
directory: result.directory,
strategy: input.strategy,
strategy: selected.id,
replace: true,
}),
)
const project = yield* db
.select({ commands: ProjectTable.commands })
.from(ProjectTable)
.where(eq(ProjectTable.id, input.projectID))
.where(eq(ProjectTable.id, projectID))
.get()
.pipe(Effect.orDie)
const command = project?.commands?.start?.trim()
@@ -294,70 +291,71 @@ const layer = Layer.effect(
})
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
yield* local
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(input.projectID, worktreeDirectory)
const stored = yield* ops.find(worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
const strategy = yield* getStrategy(StrategyID.make(stored.strategy))
yield* strategy.remove({
directory: worktreeDirectory,
force: input.force,
})
yield* changed(input.projectID, yield* ops.remove(input.projectID, worktreeDirectory))
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
yield* strategy
.remove({
directory: worktreeDirectory,
force: input.force,
})
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
yield* changed(yield* ops.remove(worktreeDirectory))
})
const refresh = Effect.fn("Worktree.refresh")(function* (input: RefreshInput) {
const stored = yield* ops.list(input.projectID)
const refresh = Effect.fn("Worktree.refresh")(function* () {
yield* local
const stored = yield* ops.list()
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(Array.from(registry.values()), (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 strategies = Array.from(state.get().strategies.values()).toReversed()
const discovered = new Map<AbsolutePath, StoredInput>()
// A location's plugin instances only discover its own checkout, not sibling clones.
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
for (const strategy of strategies) {
const entries = yield* strategy.list(location.project.directory).pipe(
Effect.mapError((error) => operationError(strategy.id, "list", error)),
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
)
for (const entry of entries) {
const directory = yield* canonical(fs, entry.directory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
)
if (!directory || discovered.has(directory)) continue
discovered.set(directory, {
directory,
strategy: entry.type === "worktree" ? strategy.id : undefined,
})
}
}
}
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const changes = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.filter(discovered, (item) =>
ops.create(
{
projectID: input.projectID,
directory: item.directory,
strategy: item.strategy,
},
tx,
),
).pipe(Effect.map((items) => items.map((item) => item.directory))),
removed: Effect.filter(removed, (directory) => ops.remove(input.projectID, directory, tx)),
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
Effect.map((items) => items.map((item) => item.directory)),
),
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
}),
)
.pipe(Effect.orDie)
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
return changes
})
return Service.of({
register,
list: ops.list,
transform: state.transform,
reload: state.reload,
list: Effect.fn("Worktree.list")(function* () {
yield* refresh()
return yield* ops.list()
}),
create,
remove,
refresh,
@@ -365,14 +363,20 @@ const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({
export const node = makeLocationNode({
service: Service,
layer: layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node],
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
})
export const refreshNode = makeLocationNode({
name: "worktree-refresh",
layer: Layer.effectDiscard(refreshAfterBoot),
deps: [node, Location.node],
})
function operationError(strategy: StrategyID, operation: string, error: unknown) {
if (
error instanceof Git.WorktreeError ||
error instanceof DirectoryUnavailableError ||
error instanceof Worktree.OperationError
)
return error
return new Worktree.OperationError({
message: `Worktree strategy ${strategy} failed to ${operation}: ${error instanceof globalThis.Error ? error.message : String(error)}`,
})
}
+28
View File
@@ -0,0 +1,28 @@
export * as WorktreeRefresh from "./refresh.js"
import { Effect, Layer } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Location } from "../location.js"
import { Plugin } from "../plugin.js"
import { PluginSupervisor } from "../plugin/supervisor.js"
import { Worktree } from "../worktree.js"
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const location = yield* Location.Service
const plugins = yield* Plugin.Service
const worktrees = yield* Worktree.Service
if (location.workspaceID) return
yield* plugins.awaitActivation.pipe(
Effect.andThen(worktrees.refresh()),
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
Effect.forkScoped,
)
}),
)
export const node = makeLocationNode({
name: "worktree-refresh",
layer,
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
})
@@ -1,3 +1,4 @@
import { Timeline } from "@opencode-ai/core/session/timeline"
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Stream } from "effect"
import { eq } from "drizzle-orm"
@@ -37,6 +38,7 @@ const seed = Effect.fn(function* (ref: Location.Ref = a) {
yield* database.db
.insert(SessionTable)
.values({
timeline_id: yield* Timeline.create(database.db),
id,
project_id: Project.ID.global,
directory: ref.directory,
@@ -131,6 +131,7 @@ describe("CodeModeInstructions.render", () => {
expect(partial).toContain("## Available tools")
expect(partial).toContain("- orders (1 tool, none shown)")
expect(partial).toContain("## Search")
expect(partial).toContain("Call `search(...)` to discover exact paths and signatures for additional tools:")
expect(partial).toContain("The Code Mode tool catalog below is partial.")
expect(partial).toContain(
"The Code Mode catalog and `search` results are the complete set of tools callable inside `execute`.",
+3 -3
View File
@@ -666,14 +666,14 @@ describe("Config", () => {
test("migrates the v1 update policy", () => {
expect(ConfigMigrateV1.migrate({ autoupdate: false }).update).toBe("disable")
expect(ConfigMigrateV1.migrate({ autoupdate: "notify" }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("notify")
expect(ConfigMigrateV1.migrate({ autoupdate: true }).update).toBe("auto")
expect(ConfigMigrateV1.migrate({}).update).toBeUndefined()
})
test("normalizes the previous native auto update policy", () => {
test("normalizes the native auto update policy", () => {
expect(ConfigNormalize.normalize({ update: "auto" })).toEqual({
type: "normalized",
encoded: { update: "notify" },
encoded: { update: "auto" },
diagnostics: [],
})
})
@@ -1,3 +1,4 @@
import { Timeline } from "@opencode-ai/core/session/timeline"
import { describe, expect } from "bun:test"
import { and, asc, eq } from "drizzle-orm"
import { Effect, Schema } from "effect"
@@ -52,6 +53,7 @@ const setup = (sessionID: SessionSchema.ID) =>
yield* db
.insert(SessionTable)
.values({
timeline_id: yield* Timeline.create(db),
id: sessionID,
project_id: Project.ID.global,
slug: "instruction-state-test",
+2
View File
@@ -1,3 +1,4 @@
import { Timeline } from "@opencode-ai/core/session/timeline"
import { describe, expect } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Agent } from "@opencode-ai/core/agent"
@@ -43,6 +44,7 @@ function setup(rules: Permission.Ruleset = [], sessionID = Session.ID.make("ses_
yield* db
.insert(SessionTable)
.values({
timeline_id: yield* Timeline.create(db),
id: sessionID,
project_id: Project.ID.global,
slug: "test",
+2
View File
@@ -32,6 +32,7 @@ import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Worktree } from "@opencode-ai/core/worktree"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
import { emptyMcpLayer } from "../fixture/mcp"
@@ -94,6 +95,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
Vcs.node,
Watcher.node,
WebSearch.node,
Worktree.node,
]),
[
Location.node.replace(tempLocationLayer),
+8
View File
@@ -145,6 +145,14 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused vcs.transform"),
reload: () => Effect.die("unused vcs.reload"),
},
worktree: overrides.worktree ?? {
list: () => Effect.die("unused worktree.list"),
create: () => Effect.die("unused worktree.create"),
remove: () => Effect.die("unused worktree.remove"),
refresh: () => Effect.die("unused worktree.refresh"),
transform: () => Effect.die("unused worktree.transform"),
reload: () => Effect.die("unused worktree.reload"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),
@@ -115,6 +115,15 @@ describe("OpenAIPlugin", () => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.1"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-6-astra"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.10"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 128_000 }
})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"), () => {})
catalog.model.update(Provider.ID.openai, Model.ID.make("gpt-4.99"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("openai"),
@@ -161,6 +170,11 @@ describe("OpenAIPlugin", () => {
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-6-astra"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.10"))).enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.04-astra"))).enabled).toBe(false)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.99"))).enabled).toBe(false)
}),
)

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