diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 72a973ebd40..58db2956619 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -22,6 +22,8 @@ ## Localization - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. +- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review. +- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar. diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 5ea9d4f7613..eacd1c94c2d 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -111,7 +111,7 @@ test("restores the draft caret before typing after a request dock closes", async }) await mockServer(page, { questions: [] }) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) - await transport.waitForConnection() + await transport.waitForConnection({ path: "/api/event" }) await expectSessionTitle(page, title) const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') @@ -132,32 +132,40 @@ test("restores the draft caret before typing after a request dock closes", async }), ) .toBe(cursor) - await transport.send({ - directory, - payload: { - type: "question.asked", - properties: { - id: "question-caret", - sessionID, - questions: [ - { - header: "Continue", - question: "Continue?", - options: [{ label: "Yes", description: "Continue the session" }], - }, - ], - tool: { messageID: "message-caret", callID: "call-caret" }, + await transport.send( + { + directory, + payload: { + type: "question.asked", + properties: { + id: "question-caret", + sessionID, + questions: [ + { + header: "Continue", + question: "Continue?", + options: [{ label: "Yes", description: "Continue the session" }], + }, + ], + tool: { messageID: "message-caret", callID: "call-caret" }, + }, }, }, - }) + undefined, + "/api/event", + ) const question = page.locator('[data-component="dock-prompt"][data-kind="question"]') await expect(question).toBeVisible() await expect(editor).toHaveCount(0) - await transport.send({ - directory, - payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, - }) + await transport.send( + { + directory, + payload: { type: "question.rejected", properties: { sessionID, requestID: "question-caret" } }, + }, + undefined, + "/api/event", + ) await expect(question).toHaveCount(0) await expect(editor).toBeVisible() await page.keyboard.press("x") diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index e99075f1fb1..178f716cf1f 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -89,13 +89,15 @@ test("reconnects after a stream error", async ({ page }) => { test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { const timeline = await setupTimeline(page, { eventRetry: 10 }) - const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { - id: "timeline-event-7", - }) + const first = await timeline.transport.send( + partUpdated(textPart("prt_transport_id", "event with id")), + { id: "timeline-event-7" }, + "/api/event", + ) await timeline.waitForPart("prt_transport_id") - await timeline.transport.error("retry with event id") - const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) + await timeline.transport.error("retry with event id", "/api/event") + const connection = await timeline.transport.waitForConnection({ after: first.connectionID, path: "/api/event" }) expect(first.eventID).toBe("timeline-event-7") expect(connection.headers["last-event-id"]).toBeUndefined() diff --git a/packages/app/e2e/regression/workspaces.spec.ts b/packages/app/e2e/regression/workspaces.spec.ts new file mode 100644 index 00000000000..6d6c67cc438 --- /dev/null +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -0,0 +1,351 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client/promise" +import { expect, test, type Page, type Route } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" +import { installSseTransport } from "../utils/sse-transport" + +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const root = "C:/OpenCode/WorkspaceProject" +const workspace = "C:/OpenCode/worktree/project/feature" +const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix" +const project = { + id: "proj_workspaces", + canonical: root, + vcs: "git" as const, + name: "workspace-project", + time: { created: 1, updated: 1 }, + sandboxes: [workspace], +} +const provider = { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, +} +const diff = { + file: "src/workspace.ts", + additions: 3, + deletions: 1, + status: "modified" as const, + patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true", +} +const cors = { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, DELETE, OPTIONS", + "access-control-allow-headers": "content-type", +} + +function session(id: string, directory: string, title?: string): SessionInfo { + return { + id, + projectID: project.id, + agent: "build", + model: { providerID: "opencode", id: "test" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + title, + location: { directory }, + subpath: "", + time: { created: 1, updated: 2 }, + } +} + +function userMessage(id: string, text: string) { + return { id, type: "user" as const, time: { created: 1 }, text } +} + +async function json(route: Route, body: unknown) { + await route.fulfill({ status: 200, contentType: "application/json", headers: cors, body: JSON.stringify(body) }) +} + +async function init(page: Page, tab: Record) { + await page.addInitScript( + ({ root, server, tab }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }), + ) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }])) + }, + { root, server, tab }, + ) +} + +test("selects an existing workspace from the start menu", async ({ page }) => { + const draftID = "draft_workspaces" + await mockOpenCodeServer(page, { + protocol: "v2", + directory: root, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await init(page, { type: "draft", draftID, directory: root }) + const directories = page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === `/api/project/${project.id}/directories`, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + await directories + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) + + await page.getByRole("button", { name: "Local", exact: true }).click() + await page.getByRole("menuitem", { name: "Workspace" }).hover() + await page.getByRole("menuitem", { name: "feature", exact: true }).click() + await expect(page.getByRole("button", { name: "feature", exact: true })).toBeVisible() +}) + +test("lists and manually deletes workspaces from settings", async ({ page }) => { + const draftID = "draft_workspace_settings" + const cleanWorkspace = `${workspace}-clean` + const inventory = { ...project, sandboxes: [cleanWorkspace] } + let releaseSessions = () => {} + const sessionsReady = new Promise((resolve) => { + releaseSessions = resolve + }) + + await mockOpenCodeServer(page, { + protocol: "v2", + directory: root, + project: inventory, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/api/session**", async (route) => { + const url = new URL(route.request().url()) + if ( + route.request().method() !== "GET" || + url.pathname !== "/api/session" || + url.searchParams.get("limit") !== "100" || + url.searchParams.get("order") !== "desc" + ) + return route.fallback() + await sessionsReady + await json(route, { data: [], cursor: {} }) + }) + await init(page, { type: "draft", draftID, directory: root }) + const directories = page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === `/api/project/${project.id}/directories`, + ) + + await page.goto(`/new-session?draftId=${draftID}`) + await directories + await expectAppVisible(page.getByRole("textbox", { name: "Prompt" })) + + await page.getByRole("button", { name: "Local", exact: true }).click() + await page.getByRole("menuitem", { name: "Workspace" }).hover() + const sessions = page.waitForRequest( + (request) => + request.method() === "GET" && + new URL(request.url()).pathname === "/api/session" && + new URL(request.url()).searchParams.get("limit") === "100", + ) + await page.getByRole("menuitem", { name: "View all", exact: true }).click() + await sessions + + const settings = page.getByRole("dialog") + await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected") + await expect(page.locator('[data-component="session-new-design"]')).toBeAttached() + releaseSessions() + await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toBeVisible() + + await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click() + const confirmation = page.getByRole("dialog").filter({ hasText: 'Delete workspace "feature-clean"?' }) + const removed = page.waitForRequest( + (request) => + request.method() === "DELETE" && + new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`, + ) + await confirmation.getByRole("button", { name: "Delete workspace", exact: true }).click() + const request = await removed + expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(root) + expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace, force: true }) + await expect(settings.getByLabel(cleanWorkspace, { exact: true })).toHaveCount(0) +}) + +test("submits the owning prompt after a new workspace is created", async ({ page }) => { + const draftID = "draft_workspace_submit" + const sessionID = "ses_workspace_submit" + const createdSession = session(sessionID, createdWorkspace) + let releaseCopy = () => {} + const copyReady = new Promise((resolve) => { + releaseCopy = resolve + }) + + await mockOpenCodeServer(page, { + protocol: "v2", + directory: root, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => { + const request = route.request() + if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors }) + if (request.method() !== "POST") return route.fallback() + await copyReady + await json(route, { directory: createdWorkspace }) + }) + await page.route("**/api/session**", async (route) => { + const request = route.request() + const url = new URL(request.url()) + const promptPath = `/api/session/${sessionID}/prompt` + if (request.method() === "OPTIONS" && (url.pathname === "/api/session" || url.pathname === promptPath)) + return route.fulfill({ status: 204, headers: cors }) + if (request.method() === "POST" && url.pathname === "/api/session") + return json(route, { data: createdSession }) + if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`) + return json(route, { data: createdSession }) + if (request.method() !== "POST" || url.pathname !== promptPath) return route.fallback() + const input = request.postDataJSON() as { id: string; text: string } + await json(route, { + data: { + id: input.id, + sessionID, + timeCreated: 3, + type: "user", + data: { text: input.text }, + delivery: "steer", + }, + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + const editor = page.getByRole("textbox", { name: "Prompt" }) + await expectAppVisible(editor) + await page.getByRole("button", { name: "Local", exact: true }).click() + await page.getByRole("menuitem", { name: "New workspace", exact: true }).click() + await editor.fill("Build workspace support") + + const copied = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`, + ) + const created = page.waitForRequest( + (request) => request.method() === "POST" && new URL(request.url()).pathname === "/api/session", + ) + const sent = page.waitForRequest( + (request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`, + ) + await page.getByRole("button", { name: "Send", exact: true }).click() + + const copyRequest = await copied + expect(new URL(copyRequest.url()).searchParams.get("location[directory]")).toBe(root) + expect(copyRequest.postDataJSON()).toEqual({ strategy: "git_worktree", directory: "C:/OpenCode" }) + releaseCopy() + + expect((await created).postDataJSON()).toEqual({ + agent: "build", + model: { id: "test", providerID: "opencode" }, + location: { directory: createdWorkspace }, + }) + const promptRequest = await sent + expect(promptRequest.postDataJSON()).toEqual({ + id: expect.stringMatching(/^msg_/), + text: "Build workspace support", + files: [], + agents: [], + }) + await expect(page.getByText("Workspace created", { exact: true })).toBeVisible() +}) + +test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({ + page, +}) => { + const sessionID = "ses_workspace_move_new" + const messageID = "msg_workspace_move_new" + const currentSession = session(sessionID, root, "Create a workspace") + const transport = await installSseTransport(page, { server }) + let releaseCopy = () => {} + const copyReady = new Promise((resolve) => { + releaseCopy = resolve + }) + let releaseMove = () => {} + const moveReady = new Promise((resolve) => { + releaseMove = resolve + }) + + await mockOpenCodeServer(page, { + protocol: "v2", + directory: root, + project, + provider, + sessions: [currentSession], + pageMessages: () => ({ items: [userMessage(messageID, "Create isolated workspace")] }), + vcsDiff: [diff], + }) + await page.route(`**/experimental/project/${project.id}/copy**`, async (route) => { + const request = route.request() + if (request.method() === "OPTIONS") return route.fulfill({ status: 204, headers: cors }) + if (request.method() !== "POST") return route.fallback() + await copyReady + await json(route, { directory: createdWorkspace }) + }) + await page.route(`**/api/session/${sessionID}**`, async (route) => { + const request = route.request() + const url = new URL(request.url()) + if (request.method() === "OPTIONS" && url.pathname === `/api/session/${sessionID}/move`) + return route.fulfill({ status: 204, headers: cors }) + if (request.method() === "GET" && url.pathname === `/api/session/${sessionID}`) + return json(route, { data: currentSession }) + if (request.method() !== "POST" || url.pathname !== `/api/session/${sessionID}/move`) + return route.fallback() + await moveReady + currentSession.location.directory = createdWorkspace + await route.fulfill({ status: 204, headers: cors }) + }) + await init(page, { type: "session", sessionId: sessionID }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await transport.waitForConnection() + await page.getByRole("button", { name: "Session details", exact: true }).click() + await page.getByRole("button", { name: "Local repository", exact: true }).click() + + const copied = page.waitForRequest( + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/experimental/project/${project.id}/copy`, + ) + const moved = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/move`, + ) + await page.getByRole("menuitem", { name: "New workspace", exact: true }).click() + + await copied + await expect(page.getByText("Creating workspace", { exact: true })).toBeVisible() + releaseCopy() + + const moveRequest = await moved + expect(moveRequest.postDataJSON()).toEqual({ directory: createdWorkspace }) + await transport.send({ + id: "evt_workspace_created", + created: 3, + type: "session.moved", + durable: { aggregateID: sessionID, seq: 1, version: 1 }, + location: { directory: root }, + data: { + sessionID, + location: { directory: createdWorkspace }, + subpath: "", + }, + }) + releaseMove() + await expect(page.getByText("Workspace created", { exact: true })).toBeVisible() +}) diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json index 514c171993a..3e4d722f553 100644 --- a/packages/app/e2e/tsconfig.json +++ b/packages/app/e2e/tsconfig.json @@ -16,6 +16,7 @@ "../src/pages/session/timeline/observe-element-offset.ts", "./regression/new-session-panel-corner.spec.ts", "./regression/session-timeline-context-resize.spec.ts", + "./regression/workspaces.spec.ts", "./utils/**/*.ts" ] } diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 93fb8a095a0..a2feb3343e8 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -175,6 +175,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (path === "/api/project") return json(route, [config.project]) if (path === "/api/project/current") return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (/^\/api\/project\/[^/]+\/directories$/.test(path)) + return json(route, [ + { directory: config.directory }, + ...((config.project as { sandboxes?: string[] }).sandboxes ?? []).map((directory) => ({ + directory, + strategy: "git_worktree", + })), + ]) if (path === "/api/location") return json(route, location(config)) const projectCopy = path.match(/^\/experimental\/project\/([^/]+)\/copy$/)?.[1] if (projectCopy && route.request().method() === "POST") { @@ -243,7 +251,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const limit = Number(url.searchParams.get("limit") ?? 50) const offset = Number(url.searchParams.get("cursor") ?? 0) const sessions = config.sessions - .filter((session) => !directory || session.directory === directory) + .filter((session) => { + const location = session.location as { directory?: string } | undefined + return !directory || location?.directory === directory || session.directory === directory + }) .filter((session) => parentID !== "null" || session.parentID === undefined) .filter((session) => { const search = url.searchParams.get("search")?.toLowerCase() @@ -466,6 +477,7 @@ function currentPermission(value: unknown) { export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { const time = session.time && typeof session.time === "object" ? session.time : {} + const location = session.location && typeof session.location === "object" ? session.location : {} return { id: session.id, parentID: session.parentID, @@ -483,10 +495,19 @@ export function currentSession(session: { id: string } & Record }, title: session.title ?? session.id, location: { - directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, - ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), + directory: + "directory" in location && typeof location.directory === "string" + ? location.directory + : typeof session.directory === "string" + ? session.directory + : fallbackDirectory, + ...(typeof session.workspaceID === "string" + ? { workspaceID: session.workspaceID } + : "workspaceID" in location && typeof location.workspaceID === "string" + ? { workspaceID: location.workspaceID } + : {}), }, - subpath: session.path, + subpath: session.subpath ?? session.path, revert: session.revert, } } diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index fdfe13f85b2..76e65c9c61f 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -29,23 +29,38 @@ export type SseEventOptions = { export type SseTransport = { server: string - waitForConnection(options?: { after?: number; timeout?: number }): Promise - send(payload: T, options?: SseEventOptions): Promise + waitForConnection(options?: { + after?: number + timeout?: number + path?: SseConnectionRecord["path"] + }): Promise + send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise heartbeat(options?: SseEventOptions): Promise writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise close(): Promise disconnect(message?: string): Promise - error(message?: string): Promise + error(message?: string, path?: SseConnectionRecord["path"]): Promise connections(): Promise acknowledgements(): Promise } type BrowserCommand = - | { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] } + | { + type: "send" + deliveries: { payload: T; options?: SseEventOptions }[] + burst: boolean + cuts?: number[] + path?: SseConnectionRecord["path"] + } | { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string } - | { type: "end"; mode: "close" | "disconnect" | "error"; message?: string } + | { + type: "end" + mode: "close" | "disconnect" | "error" + message?: string + path?: SseConnectionRecord["path"] + } | { type: "connections" } | { type: "acknowledgements" } @@ -73,7 +88,8 @@ export async function installSseTransport( let nextConnectionID = 0 let nextDeliveryID = 0 - const current = () => connections.findLast((connection) => connection.endedAt === undefined) + const current = (path?: SseConnectionRecord["path"]) => + connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path)) const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => { const boundaries = [...new Set(cuts ?? [])] .filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength) @@ -125,8 +141,8 @@ export async function installSseTransport( acknowledgements.push(acknowledgement) return acknowledgement } - const end = (mode: "close" | "disconnect" | "error", message?: string) => { - const connection = current() + const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => { + const connection = current(path) if (!connection) throw new Error("SSE transport has no active connection") connection.endedAt = performance.now() connection.endedBy = mode @@ -146,8 +162,8 @@ export async function installSseTransport( if (input.type === "connections") return connections.map(({ controller: _controller, ...connection }) => connection) if (input.type === "acknowledgements") return acknowledgements - if (input.type === "end") return end(input.mode, input.message) - const connection = current() + if (input.type === "end") return end(input.mode, input.message, input.path) + const connection = current(input.type === "send" ? input.path : undefined) if (!connection) throw new Error("SSE transport has no active connection") if (input.type === "raw") { marker(input.marker) @@ -235,12 +251,15 @@ export async function installSseTransport( server, async waitForConnection(input = {}) { const connection = await page.waitForFunction( - (after) => { + ({ after, path }) => { const transport = (window as BrowserTransport).__testSseTransport const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined - return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) + return connections?.findLast( + (connection) => + connection.id > after && connection.endedAt === undefined && (!path || connection.path === path), + ) }, - input.after ?? 0, + { after: input.after ?? 0, path: input.path }, { timeout: input.timeout }, ) let result: SseConnectionRecord | undefined @@ -252,8 +271,8 @@ export async function installSseTransport( if (!result) throw new Error("SSE transport connection disappeared while waiting") return result }, - send(payload, eventOptions) { - return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) + send(payload, eventOptions, path) { + return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path }) }, burst(payloads, eventOptions = []) { return command({ @@ -291,8 +310,8 @@ export async function installSseTransport( disconnect(message) { return command({ type: "end", mode: "disconnect", message }) }, - error(message) { - return command({ type: "end", mode: "error", message }) + error(message, path) { + return command({ type: "end", mode: "error", message, path }) }, connections() { return command({ type: "connections" }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0ea785d29ec..8a77848fa94 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,6 +1,7 @@ import "@/index.css" import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" +import type { UiI18n } from "@opencode-ai/ui/context/i18n" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { File } from "@opencode-ai/session-ui/file" @@ -156,7 +157,13 @@ function UiI18nBridge(props: ParentProps) { const language = useLanguage() return ( {props.children} diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 36256d39206..c82c1028660 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = { class?: string controller: PromptInputV2ComposerController borderUnderlay?: boolean + accentSubmit?: boolean } export type PromptInputV2ControllerProps = Omit @@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
= [] -const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] +} const optimistic: Array<{ directory?: string sessionID?: string @@ -22,11 +21,9 @@ const optimistic: Array<{ variant?: string } }> = [] -const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} -const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] -const syncedDirectories: string[] = [] +const sentShellDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] const sentPrompts: string[] = [] const promptInputs: unknown[] = [] @@ -37,15 +34,29 @@ const switchedModels: Array<{ model: { id: string; providerID: string; variant?: string } }> = [] const sessionRequestOrder: string[] = [] -const commands: Array<{ name: string }> = [] +const updatedDrafts: Array<{ draftID: string; worktree?: string }> = [] +const syncedServers: string[] = [] +const optimisticServers: string[] = [] +const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = [] let serverSessionSyncs = 0 let params: { id?: string } = {} let search: { draftId?: string } = {} let selected = "/repo/worktree-a" let variant: string | undefined -let permissionServer = "server-a" let createSessionGate: Promise | undefined +let createWorktreeGate: Promise | undefined +let worktreeFailure: Error | undefined +let worktreeHung = false +let worktreeCreates = 0 +let activeSDK = "server-a" +let activeServerSync = "server-a" +let activeDirectorySync = "server-a" +let commands: Array<{ name: string }> = [] +let worktreeDirectory = "/repo/new-0" +let worktreeID = 0 +const draftServers: Record = {} +const sessionDirectories: Record = {} let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ @@ -73,21 +84,25 @@ const prompt = { replaceComments: () => undefined, items: () => [], }, - capture: () => prompt, + capture: (scope?: unknown, target?: unknown) => { + promptCaptures.push({ scope, target }) + return prompt + }, } +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) const clientFor = (directory: string) => { - createdClients.push(directory) return { api: { session: { - create: async (input: (typeof sessionCreateInputs)[number]) => { + create: async (input: SessionCreateInput) => { await createSessionGate const location = input.location?.directory ?? directory createdSessions.push(location) - sessionCreateInputs.push(input) + const id = `session-${createdSessions.length}` + sessionDirectories[id] = location return { - id: `session-${createdSessions.length}`, + id, projectID: "project", agent: input.agent, model: input.model, @@ -100,7 +115,7 @@ const clientFor = (directory: string) => { }, prompt: async (input: unknown) => { sessionRequestOrder.push("prompt") - sentPrompts.push(directory) + sentPrompts.push(sessionDirectories[(input as { sessionID: string }).sessionID] ?? directory) promptInputs.push(input) return { data: undefined } }, @@ -120,6 +135,19 @@ const clientFor = (directory: string) => { }, shell: async (input: { sessionID: string; id?: string; command: string }) => { sentShell.push(input) + sentShellDirectories.push(sessionDirectories[input.sessionID] ?? directory) + }, + }, + projectCopy: { + create: async (_input: unknown, options?: { signal?: AbortSignal }) => { + worktreeCreates++ + if (worktreeHung) + return new Promise((_, reject) => { + options?.signal?.addEventListener("abort", () => reject(options.signal?.reason), { once: true }) + }) + await createWorktreeGate + if (worktreeFailure) throw worktreeFailure + return { directory: worktreeDirectory } }, }, }, @@ -127,9 +155,6 @@ const clientFor = (directory: string) => { command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, - worktree: { - create: async () => ({ data: { directory: `${directory}/new` } }), - }, } } @@ -145,6 +170,7 @@ beforeAll(async () => { mock.module("@opencode-ai/ui/toast", () => ({ Toast: { Region: () => null }, + toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined }, showToast: () => 0, })) @@ -162,20 +188,13 @@ beforeAll(async () => { current: () => ({ name: "agent" }), }, session: { - promote(directory: string, sessionID: string) { - promoted.push({ directory, sessionID }) - }, + promote: () => undefined, }, }), })) mock.module("@/context/permission", () => { - const state = (server: string) => ({ - enableAutoAccept(sessionID: string, directory: string) { - enabledAutoAccept.push({ server, sessionID, directory }) - }, - }) - return { usePermission: () => ({ currentServerState: () => state(permissionServer) }) } + return { usePermission: () => ({ currentServerState: () => ({ enableAutoAccept: () => undefined }) }) } }) mock.module("@/context/server", () => ({ @@ -184,7 +203,10 @@ beforeAll(async () => { mock.module("@/context/tabs", () => ({ useTabs: () => ({ - draft: () => ({ server: "project-server" }), + draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }), + updateDraft: (draftID: string, draft: { worktree?: string }) => { + updatedDrafts.push({ draftID, ...draft }) + }, promoteDraft: (draftID: string, session: { server: string; sessionId: string }) => { promotedDrafts.push({ draftID, ...session }) }, @@ -205,68 +227,70 @@ beforeAll(async () => { mock.module("@/context/sdk", () => ({ useSDK: () => { - const sdk = { - scope: "local", - directory: "/repo/main", + return () => ({ + scope: activeSDK === "server-a" ? ServerScope.local : "server-b", + directory: activeSDK === "server-a" ? "/repo/main" : "/repo/other", api: rootClient.api, url: "http://localhost:4096", - } - return () => sdk + }) }, })) mock.module("@/context/sync", () => ({ - useSync: () => () => ({ - data: { command: commands }, - session: { - optimistic: { - add: (value: { - directory?: string - sessionID?: string - message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } - }) => { - optimistic.push(value) - optimisticSeeded.push( - !!value.directory && - !!value.sessionID && - !!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title, - ) + useSync: () => () => { + const server = activeDirectorySync + return { + data: { command: commands, project: "project" }, + session: { + optimistic: { + add: (value: { + directory?: string + sessionID?: string + message: { agent: string; model: { providerID: string; modelID: string; variant?: string } } + }) => { + optimisticServers.push(server) + optimistic.push(value) + }, + remove: () => undefined, }, - remove: () => undefined, }, - }, - set: () => undefined, - }), + set: () => undefined, + project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" }, + } + }, })) mock.module("@/context/server-sync", () => ({ - useServerSync: () => () => ({ - session: { - remember: () => undefined, - set: () => undefined, - sync: async () => { - serverSessionSyncs++ - }, - }, - child: (directory: string) => { - syncedDirectories.push(directory) - storedSessions[directory] ??= [] - return [ - { session: storedSessions[directory] }, - (...args: unknown[]) => { - if (args[0] !== "session") return - const next = args[1] - if (typeof next === "function") { - storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }> - return - } - if (Array.isArray(next)) { - storedSessions[directory] = next as Array<{ id: string; title?: string }> - } + useServerSync: () => () => { + const server = activeServerSync + return { + session: { + remember: () => undefined, + set: () => undefined, + sync: async () => { + serverSessionSyncs++ }, - ] - }, - }), + }, + child: (directory: string) => { + syncedServers.push(server) + storedSessions[directory] ??= [] + return [ + { session: storedSessions[directory] }, + (...args: unknown[]) => { + if (args[0] !== "session") return + const next = args[1] + if (typeof next === "function") { + storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }> + return + } + if (Array.isArray(next)) { + storedSessions[directory] = next as Array<{ id: string; title?: string }> + } + }, + ] + }, + } + }, })) mock.module("@/context/platform", () => ({ @@ -286,205 +310,159 @@ beforeAll(async () => { }) beforeEach(() => { - createdClients.length = 0 createdSessions.length = 0 - sessionCreateInputs.length = 0 - enabledAutoAccept.length = 0 optimistic.length = 0 - optimisticSeeded.length = 0 - promoted.length = 0 promotedDrafts.length = 0 + updatedDrafts.length = 0 + sentCommands.length = 0 sentPrompts.length = 0 promptInputs.length = 0 - sentCommands.length = 0 switchedAgents.length = 0 switchedModels.length = 0 sessionRequestOrder.length = 0 - commands.length = 0 - promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] + syncedServers.length = 0 + optimisticServers.length = 0 + promptCaptures.length = 0 params = {} search = {} sentShell.length = 0 - syncedDirectories.length = 0 + sentShellDirectories.length = 0 selected = "/repo/worktree-a" variant = undefined - permissionServer = "server-a" + activeSDK = "server-a" + activeServerSync = "server-a" + activeDirectorySync = "server-a" + commands = [] + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] + worktreeDirectory = `/repo/new-${++worktreeID}` createSessionGate = undefined serverSessionSyncs = 0 + createWorktreeGate = undefined + worktreeFailure = undefined + worktreeHung = false + worktreeCreates = 0 + for (const key of Object.keys(draftServers)) delete draftServers[key] + for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] for (const key of Object.keys(storedSessions)) delete storedSessions[key] }) +const event = { preventDefault: () => undefined } as unknown as Event +const makeSubmit = (overrides: Partial[0]> = {}) => + createPromptSubmit({ + prompt, + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + newSessionWorktree: () => selected, + onNewSessionWorktreeReset: () => undefined, + onSubmit: () => undefined, + ...overrides, + }) + describe("prompt submit worktree selection", () => { - test("reads the latest worktree accessor value per submit", async () => { - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, + test("admits only one concurrent new-workspace submission", async () => { + selected = "create" + let release = () => {} + createWorktreeGate = new Promise((resolve) => { + release = resolve }) + const submit = makeSubmit() - const event = { preventDefault: () => undefined } as unknown as Event + const first = submit.handleSubmit(event) + const duplicate = submit.handleSubmit(event) + expect(worktreeCreates).toBe(1) - await submit.handleSubmit(event) - selected = "/repo/worktree-b" - await submit.handleSubmit(event) + release() + await Promise.all([first, duplicate]) + expect(createdSessions).toEqual([worktreeDirectory]) + await settle() - expect(createdClients).toEqual([]) - expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sessionCreateInputs).toEqual([ - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-a" }, - }, - { - agent: "agent", - model: { id: "model", providerID: "provider", variant: undefined }, - location: { directory: "/repo/worktree-b" }, - }, - ]) - expect(sentShell).toEqual([ - expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), - expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), - ]) - expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) - expect(serverSessionSyncs).toBe(0) - expect(promoted).toEqual([ - { directory: "/repo/worktree-a", sessionID: "session-1" }, - { directory: "/repo/worktree-b", sessionID: "session-2" }, - ]) - expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) + expect(worktreeCreates).toBe(1) + expect(createdSessions).toHaveLength(1) + expect(sentPrompts).toEqual([worktreeDirectory]) }) - test("applies auto-accept to newly created sessions", async () => { - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, + test("aborts a hung new-workspace request and allows retry", async () => { + selected = "create" + worktreeHung = true + let resets = 0 + const submit = makeSubmit({ + onNewSessionWorktreeReset: () => resets++, + worktreeRequestTimeoutMs: 1, }) - const event = { preventDefault: () => undefined } as unknown as Event - await submit.handleSubmit(event) - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) + expect(worktreeCreates).toBe(1) + expect(createdSessions).toEqual([]) + expect(selected).toBe("create") + expect(promptValue).toEqual([{ type: "text", content: "ls", start: 0, end: 2 }]) + expect(resets).toBe(0) + + worktreeHung = false + await submit.handleSubmit(event) + await settle() + + expect(worktreeCreates).toBe(2) + expect(createdSessions).toEqual([worktreeDirectory]) + expect(sentPrompts).toEqual([worktreeDirectory]) + expect(resets).toBe(1) }) - test("keeps auto-accept bound to the submission server", async () => { + test("keeps async submission effects bound to the initiating context", async () => { + search = { draftId: "draft-1" } + draftServers["draft-1"] = "project-server-a" + draftServers["draft-2"] = "project-server-b" let release = () => {} createSessionGate = new Promise((resolve) => { release = resolve }) - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => true, - mode: () => "shell", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, + let submitted = 0 + const submit = makeSubmit({ + onSubmit: () => submitted++, }) - const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - permissionServer = "server-b" + const result = submit.handleSubmit(event) + activeSDK = "server-b" + activeServerSync = "server-b" + activeDirectorySync = "server-b" + search.draftId = "draft-2" release() await result + await settle() - expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }]) - }) - - test("promotes drafts using the selected project's server", async () => { - search = { draftId: "draft-1" } - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }]) + expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }]) + expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }]) + expect(syncedServers.every((server) => server === "server-a")).toBe(true) + expect(optimisticServers).toEqual(["server-a"]) + expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local }) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete") + expect(WorkspaceOperation.get("server-b" as ServerScope, "session-1")).toBeUndefined() + expect(submitted).toBe(0) }) test("switches the selected agent and model before prompting", async () => { params = { id: "session-1" } variant = "high" - const submit = createPromptSubmit({ - prompt, + const submit = makeSubmit({ info: () => ({ id: "session-1", agent: "old-agent", model: { id: "old-model", providerID: "old-provider" }, }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - onSubmit: () => undefined, }) - const event = { preventDefault: () => undefined } as unknown as Event - await submit.handleSubmit(event) await Bun.sleep(0) @@ -519,24 +497,12 @@ describe("prompt submit worktree selection", () => { commands.push({ name: "review" }) promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] - const submit = createPromptSubmit({ - prompt, + const submit = makeSubmit({ info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, }) - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + await submit.handleSubmit(event) + await settle() expect(sentCommands).toEqual([ { @@ -552,66 +518,20 @@ describe("prompt submit worktree selection", () => { expect(serverSessionSyncs).toBe(0) }) - test("uses an injected model selection", async () => { - params = { id: "session-1" } - const model = { - current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), - variant: { current: () => "draft-variant" }, - } as unknown as ModelSelection - const submit = createPromptSubmit({ - prompt, - info: () => ({ id: "session-1" }), - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - model, + test("sends an initial shell after synchronous workspace creation", async () => { + selected = "create" + const submit = makeSubmit({ + mode: () => "shell", }) - await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) - - expect(optimistic[0]).toMatchObject({ - message: { - model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, - }, - }) - }) - - test("seeds new sessions before optimistic prompts are added", async () => { - const submit = createPromptSubmit({ - prompt, - info: () => undefined, - imageAttachments: () => [], - commentCount: () => 0, - autoAccept: () => false, - mode: () => "normal", - working: () => false, - editor: () => undefined, - queueScroll: () => undefined, - promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), - addToHistory: () => undefined, - resetHistoryNavigation: () => undefined, - setMode: () => undefined, - setPopover: () => undefined, - newSessionWorktree: () => selected, - onNewSessionWorktreeReset: () => undefined, - onSubmit: () => undefined, - }) - - const event = { preventDefault: () => undefined } as unknown as Event - await submit.handleSubmit(event) + await settle() - expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) - expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) - expect(optimisticSeeded).toEqual([true]) + expect(sentShellDirectories).toEqual([worktreeDirectory]) + expect(sentShell[0]).toMatchObject({ + sessionID: "session-1", + command: "ls", + }) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete") }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 4a402815675..1597bd770b4 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -17,10 +17,12 @@ import { useSync, type DirectorySync } from "@/context/sync" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" import { getDirectory } from "@opencode-ai/core/util/path" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" -import { ScopedKey } from "@/utils/server-scope" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { Event } from "@opencode-ai/schema/event" import { blobDataUrl } from "@/utils/draft-store" @@ -28,9 +30,13 @@ import { blobDataUrl } from "@/utils/draft-store" type PendingPrompt = { abort: AbortController cleanup: VoidFunction + scope: ServerScope + sessionID: string + serverSync: ServerSync } const pending = new Map() +const submitting = new Set() export type FollowupDraft = { sessionID: string @@ -44,6 +50,7 @@ export type FollowupDraft = { type FollowupSendInput = { api: DirectorySDK["api"]["session"] + scope: ServerScope serverSync: ServerSync sync: DirectorySync session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> @@ -58,6 +65,8 @@ const draftText = (prompt: Prompt) => prompt.map((part) => ("content" in part ? const draftImages = (prompt: Prompt) => prompt.filter((part): part is ImageAttachmentPart => part.type === "image") export async function sendFollowupDraft(input: FollowupSendInput) { + const operation = WorkspaceOperation.get(input.scope, input.draft.sessionID) + if (operation?.status === "pending" && operation.messageID !== input.messageID) return false const text = draftText(input.draft.prompt) const images = draftImages(input.draft.prompt) const setBusy = () => { @@ -248,6 +257,7 @@ type PromptSubmitInput = { onAbort?: () => void onSubmit?: () => void model?: ModelSelection + worktreeRequestTimeoutMs?: number } export function createPromptSubmit(input: PromptSubmitInput) { @@ -263,7 +273,8 @@ export function createPromptSubmit(input: PromptSubmitInput) { const params = useParams() const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() - const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) + const pendingKey = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID) + let pendingSubmission: { key: string; scope: ServerScope; sessionID: string } | undefined const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message @@ -276,18 +287,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { } const abort = async () => { - const sessionID = params.id + const routeSessionID = params.id + const owned = + pendingSubmission && (!routeSessionID || routeSessionID === pendingSubmission.sessionID) + ? pending.get(pendingSubmission.key) + : undefined + const sessionID = routeSessionID ?? owned?.sessionID if (!sessionID) return Promise.resolve() - - serverSync().session.set("todo", sessionID, []) + ;(owned?.serverSync ?? serverSync()).session.set("todo", sessionID, []) input.onAbort?.() - const key = pendingKey(sessionID) - const queued = pending.get(key) + const key = owned ? pendingSubmission!.key : pendingKey(sdk().scope, sessionID) + const queued = owned ?? pending.get(key) if (queued) { queued.abort.abort() queued.cleanup() + WorkspaceOperation.fail(queued.scope, queued.sessionID) pending.delete(key) return Promise.resolve() } @@ -319,9 +335,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const seed = (dir: string, info: SessionInfo) => { - serverSync().session.remember(info) - const [, setStore] = serverSync().child(dir) + const seed = (target: ServerSync, dir: string, info: SessionInfo) => { + target.session.remember(info) + const [, setStore] = target.child(dir) setStore("session", (list: SessionInfo[]) => { const result = Binary.search(list, info.id, (item) => item.id) const next = [...list] @@ -353,6 +369,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (input.working()) void abort() return } + if (params.id && WorkspaceOperation.get(sdk().scope, params.id)?.status === "pending") return const modelSelection = input.model ?? local.model const currentModel = modelSelection.current() @@ -366,284 +383,369 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - input.addToHistory(currentPrompt, mode) - input.resetHistoryNavigation() - - const projectDirectory = sdk().directory + const submissionSDK = sdk() + const submissionSync = sync() + const submissionServerSync = serverSync() + const submissionScope = submissionSDK.scope + const projectDirectory = submissionSDK.directory + const projectRoot = submissionSync.project?.worktree ?? projectDirectory + const sessionID = params.id + const isNewSession = !sessionID + const currentSession = input.info() + const draftID = search.draftId + const draftServer = draftID ? tabs.draft(draftID).server : undefined + const capturePrompt = prompt.capture + const localSession = local.session + const handoff = layout.handoff + const resetWorktree = input.onNewSessionWorktreeReset + const onSubmit = input.onSubmit const permissionState = permission.currentServerState() - const isNewSession = !params.id const shouldAutoAccept = isNewSession && input.autoAccept() const worktreeSelection = input.newSessionWorktree?.() || "main" + const submissionKey = ScopedKey.from( + submissionScope, + draftID ? `draft:${draftID}` : sessionID ? `session:${sessionID}` : `directory:${projectDirectory}`, + ) + if (submitting.has(submissionKey)) return + submitting.add(submissionKey) - let sessionDirectory = projectDirectory - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await sdk() - .api.projectCopy.create({ - projectID: sync().data.project, - strategy: "git_worktree", - directory: getDirectory(projectDirectory), - location: { directory: projectDirectory }, + try { + input.addToHistory(currentPrompt, mode) + input.resetHistoryNavigation() + + let sessionDirectory = projectDirectory + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await workspaceRequestWithTimeout( + (signal) => + submissionSDK.api.projectCopy.create( + { + projectID: submissionSync.data.project, + strategy: "git_worktree", + directory: getDirectory(projectDirectory), + location: { directory: projectDirectory }, + }, + { signal }, + ), + language.t("prompt.toast.worktreeCreateFailed.title"), + input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + + if (!createdWorktree) return + WorktreeState.ready(submissionScope, createdWorktree.directory) + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + submissionServerSync.child(sessionDirectory) + } + } + + let session = currentSession + if (!session && isNewSession) { + const created = await submissionSDK.api.session + .create({ + agent: currentAgent.name, + model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, + location: { directory: sessionDirectory }, }) .catch((err) => { showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), + title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), }) return undefined }) - if (!createdWorktree) return - WorktreeState.pending(sdk().scope, createdWorktree.directory) - sessionDirectory = createdWorktree.directory - } - - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection - } - - if (sessionDirectory !== projectDirectory) { - serverSync().child(sessionDirectory) - } - - input.onNewSessionWorktreeReset?.() - } - - let session = input.info() - if (!session && isNewSession) { - const created = await sdk() - .api.session.create({ - agent: currentAgent.name, - model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, - location: { directory: sessionDirectory }, - }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), + if (created) { + seed(submissionServerSync, sessionDirectory, created) + session = created + await startTransition(() => { + if (!session) return + if (draftID) tabs.updateDraft(draftID, { worktree: undefined }) + if (!draftID) resetWorktree?.() + if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) + localSession.promote(sessionDirectory, session.id, { + agent: currentAgent.name, + model: { providerID: currentModel.provider.id, modelID: currentModel.id }, + variant: variant ?? null, + }) + handoff.setTabs(base64Encode(sessionDirectory), session.id) + if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget( + capturePrompt( + { dir: base64Encode(sessionDirectory), id: session.id }, + { server: draftServer, scope: submissionScope }, + ), + ) }) - return undefined - }) - if (created) { - seed(sessionDirectory, created) - session = created - await startTransition(() => { - if (!session) return - if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id, { - agent: currentAgent.name, - model: { providerID: currentModel.provider.id, modelID: currentModel.id }, - variant: variant ?? null, - }) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) - }) + } + } + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return } - } - if (!session) { - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: language.t("prompt.toast.promptSendFailed.description"), - }) - return - } - const model = { - modelID: currentModel.id, - providerID: currentModel.provider.id, - } - const agent = currentAgent.name - const draft: FollowupDraft = { - sessionID: session.id, - sessionDirectory, - prompt: currentPrompt, - context, - agent, - model, - variant, - } + const model = { + modelID: currentModel.id, + providerID: currentModel.provider.id, + } + const agent = currentAgent.name + const draft: FollowupDraft = { + sessionID: session.id, + sessionDirectory, + prompt: currentPrompt, + context, + agent, + model, + variant, + } - const clearInput = () => { - submission.clear() - input.setMode("normal") - input.setPopover(null) - } + const clearInput = () => { + submission.clear() + input.setMode("normal") + input.setPopover(null) + } - const restoreInput = () => { - const restored = submission.restore() - if (!restored) return false - restored.target.set(restored.prompt, input.promptLength(restored.prompt)) - if (!submission.current(prompt.capture())) return true - input.setMode(mode) - input.setPopover(null) - requestAnimationFrame(() => { - const editor = input.editor() - if (!editor) return - editor.focus() - setCursorPosition(editor, input.promptLength(currentPrompt)) - input.queueScroll() - }) - return true - } + const restoreInput = () => { + const restored = submission.restore() + if (!restored) return false + restored.target.set(restored.prompt, input.promptLength(restored.prompt)) + if (!submission.current(prompt.capture())) return true + input.setMode(mode) + input.setPopover(null) + requestAnimationFrame(() => { + const editor = input.editor() + if (!editor) return + editor.focus() + setCursorPosition(editor, input.promptLength(currentPrompt)) + input.queueScroll() + }) + return true + } - if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { - input.onQueue?.(draft) - clearContext(submission.target()) - clearInput() - return - } + if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + input.onQueue?.(draft) + clearContext(submission.target()) + clearInput() + return + } - input.onSubmit?.() + const startWorkspaceOperation = (messageID: string) => { + if (!isNewSession) return + if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) { + WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID) + WorkspaceOperation.complete(submissionScope, session.id) + } + if (worktreeSelection !== "create") return + const worktree = WorktreeState.get(submissionScope, sessionDirectory) + WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID) + if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id) + if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id) + } - if (mode === "shell") { - clearInput() - const eventID = Event.ID.create() - sdk() - .api.session.shell({ + const waitForWorktree = async (cleanup: VoidFunction) => { + const worktree = WorktreeState.get(submissionScope, sessionDirectory) + if (!worktree) return true + if (worktree.status === "ready") { + WorkspaceOperation.complete(submissionScope, session.id) + return true + } + if (worktree.status === "failed") { + WorkspaceOperation.fail(submissionScope, session.id) + throw new Error(worktree.message) + } + + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "busy" }) + } + + const controller = new AbortController() + const key = pendingKey(submissionScope, session.id) + pendingSubmission = { key, scope: submissionScope, sessionID: session.id } + pending.set(key, { + abort: controller, + cleanup, + scope: submissionScope, sessionID: session.id, - id: eventID, - command: text, + serverSync: submissionServerSync, }) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.shellSendFailed.title"), - description: errorMessage(err), - }) + + const abortWait = new Promise>>((resolve) => { + if (controller.signal.aborted) { + resolve({ status: "failed", message: "aborted" }) + return + } + controller.signal.addEventListener( + "abort", + () => { + resolve({ status: "failed", message: "aborted" }) + }, + { once: true }, + ) + }) + + const timeoutMs = 5 * 60 * 1000 + const timer = { id: undefined as number | undefined } + const timeout = new Promise>>((resolve) => { + timer.id = window.setTimeout(() => { + resolve({ + status: "failed", + message: language.t("workspace.error.stillPreparing"), + }) + }, timeoutMs) + }) + + const result = await Promise.race([ + WorktreeState.wait(submissionScope, sessionDirectory), + abortWait, + timeout, + ]).finally(() => { + pending.delete(key) + if (pendingSubmission?.key === key) pendingSubmission = undefined + if (timer.id === undefined) return + clearTimeout(timer.id) + }) + if (controller.signal.aborted) return false + if (result.status === "failed") { + WorkspaceOperation.fail(submissionScope, session.id) + throw new Error(result.message) + } + WorkspaceOperation.complete(submissionScope, session.id) + return true + } + + if (!draftID || search.draftId === draftID) onSubmit?.() + + if (mode === "shell") { + clearInput() + const eventID = Event.ID.create() + startWorkspaceOperation(eventID) + void waitForWorktree(() => { restoreInput() }) - return - } - - if (text.startsWith("/")) { - const [cmdName, ...args] = text.split(" ") - const commandName = cmdName.slice(1) - const customCommand = sync().data.command.find((c) => c.name === commandName) - if (customCommand) { - clearInput() - const messageID = Identifier.ascending("message") - serverSync().session.set("session_status", session.id, { type: "busy" }) - sdk() - .api.session.command({ - sessionID: session.id, - id: messageID, - command: commandName, - arguments: args.join(" "), - agent, - model: { id: model.modelID, providerID: model.providerID, variant }, - files: await Promise.all( - images.map(async (attachment) => ({ - uri: await blobDataUrl(attachment.blob, attachment.mime), - name: attachment.filename, - })), - ), + .then((ready) => { + if (!ready) return + return submissionSDK.api.session.shell({ + sessionID: session.id, + id: eventID, + command: text, + }) }) .catch((err) => { - serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ - title: language.t("prompt.toast.commandSendFailed.title"), - description: formatServerError(err, language.t, language.t("common.requestFailed")), + title: language.t("prompt.toast.shellSendFailed.title"), + description: errorMessage(err), }) restoreInput() }) return } - } - const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) - const messageID = Identifier.ascending("message") - - const removeOptimisticMessage = () => { - sync().session.optimistic.remove({ - directory: sessionDirectory, - sessionID: session.id, - messageID, - }) - } - - for (const item of commentItems) submission.target().context.remove(item.key) - clearInput() - - const waitForWorktree = async () => { - const worktree = WorktreeState.get(sdk().scope, sessionDirectory) - if (!worktree || worktree.status !== "pending") return true - - if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "busy" }) + if (text.startsWith("/")) { + const [cmdName, ...args] = text.split(" ") + const commandName = cmdName.slice(1) + const customCommand = submissionSync.data.command.find((c) => c.name === commandName) + if (customCommand) { + clearInput() + const messageID = Identifier.ascending("message") + startWorkspaceOperation(messageID) + submissionServerSync.session.set("session_status", session.id, { type: "busy" }) + void waitForWorktree(() => { + submissionServerSync.session.set("session_status", session.id, { type: "idle" }) + restoreInput() + }) + .then(async (ready) => { + if (!ready) return + return submissionSDK.api.session.command({ + sessionID: session.id, + id: messageID, + command: commandName, + arguments: args.join(" "), + agent, + model: { id: model.modelID, providerID: model.providerID, variant }, + files: await Promise.all( + images.map(async (attachment) => ({ + uri: await blobDataUrl(attachment.blob, attachment.mime), + name: attachment.filename, + })), + ), + }) + }) + .catch((err) => { + submissionServerSync.session.set("session_status", session.id, { type: "idle" }) + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: formatServerError(err, language.t, language.t("common.requestFailed")), + }) + restoreInput() + }) + return + } } - const controller = new AbortController() + const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim()) + const messageID = Identifier.ascending("message") + startWorkspaceOperation(messageID) + + const removeOptimisticMessage = () => { + submissionSync.session.optimistic.remove({ + directory: sessionDirectory, + sessionID: session.id, + messageID, + }) + } + + for (const item of commentItems) submission.target().context.remove(item.key) + clearInput() + const cleanup = () => { if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "idle" }) + submissionSync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() if (restoreInput()) restoreCommentItems(submission.target(), commentItems) } - pending.set(pendingKey(session.id), { abort: controller, cleanup }) - - const abortWait = new Promise>>((resolve) => { - if (controller.signal.aborted) { - resolve({ status: "failed", message: "aborted" }) - return + void sendFollowupDraft({ + api: submissionSDK.api.session, + scope: submissionScope, + sync: submissionSync, + serverSync: submissionServerSync, + session: () => session, + draft, + messageID, + optimisticBusy: sessionDirectory === projectDirectory, + before: () => waitForWorktree(cleanup), + }).catch((err) => { + pending.delete(pendingKey(submissionScope, session.id)) + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "idle" }) } - controller.signal.addEventListener( - "abort", - () => { - resolve({ status: "failed", message: "aborted" }) - }, - { once: true }, - ) + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: errorMessage(err), + }) + removeOptimisticMessage() + if (restoreInput()) restoreCommentItems(submission.target(), commentItems) }) - - const timeoutMs = 5 * 60 * 1000 - const timer = { id: undefined as number | undefined } - const timeout = new Promise>>((resolve) => { - timer.id = window.setTimeout(() => { - resolve({ - status: "failed", - message: language.t("workspace.error.stillPreparing"), - }) - }, timeoutMs) - }) - - const result = await Promise.race([ - WorktreeState.wait(sdk().scope, sessionDirectory), - abortWait, - timeout, - ]).finally(() => { - if (timer.id === undefined) return - clearTimeout(timer.id) - }) - pending.delete(pendingKey(session.id)) - if (controller.signal.aborted) return false - if (result.status === "failed") throw new Error(result.message) - return true + } finally { + submitting.delete(submissionKey) } - - void sendFollowupDraft({ - api: sdk().api.session, - sync: sync(), - serverSync: serverSync(), - session: () => input.info() ?? session, - draft, - messageID, - optimisticBusy: sessionDirectory === projectDirectory, - before: waitForWorktree, - }).catch((err) => { - pending.delete(pendingKey(session.id)) - if (sessionDirectory === projectDirectory) { - sync().set("session_status", session.id, { type: "idle" }) - } - showToast({ - title: language.t("prompt.toast.promptSendFailed.title"), - description: errorMessage(err), - }) - removeOptimisticMessage() - if (restoreInput()) restoreCommentItems(submission.target(), commentItems) - }) } return { diff --git a/packages/app/src/components/prompt-workspace-selector.tsx b/packages/app/src/components/prompt-workspace-selector.tsx index a7a35374341..a86c5b865c4 100644 --- a/packages/app/src/components/prompt-workspace-selector.tsx +++ b/packages/app/src/components/prompt-workspace-selector.tsx @@ -1,8 +1,7 @@ -import { For, Show } from "solid-js" +import { createMemo, createSignal, For, Show } from "solid-js" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { Icon } from "@opencode-ai/ui/icon" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { Icon } from "@opencode-ai/ui/v2/icon" import { getFilename } from "@opencode-ai/core/util/path" import { useLanguage } from "@/context/language" @@ -11,25 +10,42 @@ export function PromptWorkspaceSelector(props: { projectRoot: string workspaces: string[] branch?: string + onboarding?: boolean onChange: (value: string) => void onDone: () => void + onViewAll: () => void }) { const language = useLanguage() - let pending: string | undefined + const [search, setSearch] = createSignal("") + let searchInput: HTMLInputElement | undefined + let focusSearch = false + let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined const selected = () => (props.value === props.projectRoot ? "main" : props.value) + const workspaces = createMemo(() => { + const query = search().trim().toLowerCase() + if (!query) return props.workspaces + return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query)) + }) const icon = () => { if (selected() === "main") return "monitor" if (selected() === "create") return "workspace-new" - return "workspace" + return "workspace-isolated" } const select = (value: string) => { - pending = value + pending = { type: "select", value } } const onOpenChange = (open: boolean) => { - if (open) return - const value = pending + if (open) { + setSearch("") + return + } + const action = pending pending = undefined - if (value) props.onChange(value) + if (action?.type === "select") props.onChange(action.value) + if (action?.type === "viewAll") { + props.onViewAll() + return + } props.onDone() } const label = () => { @@ -41,87 +57,220 @@ export function PromptWorkspaceSelector(props: { return ( <> - - - - {label()} - - - - - - {language.t("session.new.workspace.runIn")} - select("main")}> - - {language.t("session.new.workspace.local")} - - - - - select("create")}> - - {language.t("workspace.new")} - - - - - - 0}> - - - - - {language.t("session.new.workspace.existing")} - - - - - {(workspace) => ( - select(workspace)}> - - {getFilename(workspace)} - - - - - )} - - - - + +
+ + {language.t("workspace.onboarding.title")} +
+ + {language.t("workspace.onboarding.description")} + +
+ ) : ( + language.t("session.new.workspace.trigger.tooltip") + ) + } + contentClass={props.onboarding ? "max-w-[280px]" : undefined} + class="min-w-0" + > + + + + {label()} + + - - - - + + + + + + {language.t("session.new.workspace.runIn")} + select("main")}> + + + {language.t("session.new.workspace.local")} + + {language.t("session.new.workspace.local.tooltip")} + + + } + class="min-w-0 flex-1" + > + {language.t("session.new.workspace.local")} + + + + + + select("create")}> + + + {language.t("workspace.new")} + + {language.t("session.new.workspace.new.tooltip")} + + + } + class="min-w-0 flex-1" + > + {language.t("workspace.new")} + + + + + + + 0} + fallback={ + <> + + (pending = { type: "viewAll" })}> + {language.t("common.viewAll")} + + + } + > + + { + if (!open) { + focusSearch = false + return + } + if (!focusSearch || props.workspaces.length < 10) return + focusSearch = false + requestAnimationFrame(() => searchInput?.focus()) + }} + > + { + if ( + event.key === "ArrowRight" || + event.key === "ArrowLeft" || + event.key === "Enter" || + event.key === " " + ) + focusSearch = true + }} + > + + + {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} + + + + + = 10}> +
+ + { + searchInput = element + }} + value={search()} + placeholder={language.t("session.new.workspace.search.placeholder")} + aria-label={language.t("session.new.workspace.search.placeholder")} + class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint" + onInput={(event) => setSearch(event.currentTarget.value)} + onKeyDown={(event) => { + if ( + event.key === "Escape" || + event.key === "ArrowDown" || + event.key === "ArrowUp" || + event.key === "Enter" + ) + return + event.stopPropagation() + }} + /> +
+
+ + {(workspace) => ( + select(workspace)}> + + {getFilename(workspace)} + + + + + )} + + + (pending = { type: "viewAll" })}> + {language.t("common.viewAll")} + +
+
+
+
+
+
+ + + ) } -export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) { +export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) { const language = useLanguage() const label = () => { if (props.noGit) return language.t("session.new.git.none") + if (!props.branch) return undefined + if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch }) return props.branch } + const icon = () => { + if (props.noGit) return "monitor" + if (props.from) return "branch-out" + return "branch" + } + return ( {(value) => ( - <> - - -
- - {value()} -
-
- + +
+ + {value()} +
+
)}
) diff --git a/packages/app/src/components/session-workspace-menu.tsx b/packages/app/src/components/session-workspace-menu.tsx new file mode 100644 index 00000000000..2ee7fc7f0b2 --- /dev/null +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -0,0 +1,189 @@ +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { getDirectory, getFilename } from "@opencode-ai/core/util/path" +import { createStore } from "solid-js/store" +import { For, Show, type ComponentProps, type JSX } from "solid-js" +import type { Project } from "@/types" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useSettingsDialog } from "@/components/settings-dialog" +import { pathKey } from "@/utils/path-key" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { showToast } from "@/utils/toast" +import type { ServerScope } from "@/utils/server-scope" +import { workspaceDirectories } from "@/utils/workspace" +import { + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + WORKSPACE_PREPARATION_TIMEOUT_MS, + workspaceRequestWithTimeout, +} from "@/utils/workspace-request" + +export function SessionWorkspaceMenu(props: { + eligible?: boolean + sessionID: string + project: Project + directory: string + messageID?: string + placement?: ComponentProps["placement"] + gutter?: number + class?: string + contentClass?: string + children: JSX.Element + onOpenChange?: (open: boolean) => void +}) { + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const openWorkspaces = useSettingsDialog("workspaces") + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending" + const blocked = () => + props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID) + const workspaces = () => + workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory)) + + const fail = (scope: ServerScope, sessionID: string, message: string) => { + if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return + WorkspaceOperation.fail(scope, sessionID) + showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message }) + } + const move = async (selection: "create" | string) => { + if (store.selected || blocked()) return + const sdk = serverSDK() + const sync = serverSync() + const scope = sdk.scope + const sessionID = props.sessionID + const messageID = props.messageID + const source = props.directory + setStore("selected", selection) + + try { + const destination = + selection === "create" + ? await createWorkspace( + props.project, + source, + sessionID, + messageID, + sdk, + (message) => fail(scope, sessionID, message), + { + createFailed: language.t("prompt.toast.worktreeCreateFailed.title"), + }, + ) + : selection + if (!destination) return + + WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID) + if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed")) + await workspaceRequestWithTimeout( + (signal) => sdk.api.session.move({ sessionID, directory: destination }, { signal }), + language.t("workspace.move.failed"), + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + const session = await workspaceRequestWithTimeout( + (signal) => sync.session.resolve(sessionID, { force: true, signal }), + language.t("workspace.move.failed"), + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + ) + if (!session || pathKey(session.location.directory) !== pathKey(destination)) + throw new Error(language.t("workspace.move.failed")) + WorkspaceOperation.complete(scope, sessionID, destination) + sync.reindexSession(sessionID, source) + } catch (error) { + fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")) + } finally { + setStore("selected", undefined) + } + } + + return ( + + + {props.children} + + + + + {language.t("workspace.move.menu.title")} + + void move(props.project.worktree)}> + + {language.t("session.new.workspace.local")} + + + void move("create")}> + + {language.t("workspace.new")} + + 0}> + + + + {language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")} + + + + + {(workspace) => ( + void move(workspace)}> + + {getFilename(workspace)} + + )} + + + + + + + + openWorkspaces()}> + {language.t("common.viewAll")} + + + + + ) +} + +async function createWorkspace( + project: Project, + source: string, + sessionID: string, + messageID: string | undefined, + serverSDK: ReturnType>, + fail: (message: string) => void, + messages: { createFailed: string }, +) { + WorkspaceOperation.start(serverSDK.scope, sessionID, "create", project.worktree, messageID) + const created = await workspaceRequestWithTimeout( + (signal) => + serverSDK.api.projectCopy.create( + { + projectID: project.id, + strategy: "git_worktree", + directory: getDirectory(source), + location: { directory: source }, + }, + { signal }, + ), + messages.createFailed, + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .catch((error) => { + fail(error instanceof Error ? error.message : messages.createFailed) + return undefined + }) + if (!created?.directory) return + WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID) + Worktree.ready(serverSDK.scope, created.directory) + return created.directory +} diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index 8507846f877..d33f23d26bd 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,6 +10,7 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" +import { SettingsWorkspacesV2 } from "./workspaces" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLayout } from "@/context/layout" import { useTabs } from "@/context/tabs" @@ -58,11 +59,11 @@ export const DialogSettings: Component<{ {language.t("settings.section.desktop")}
- + {language.t("settings.tab.general")} - + {language.t("settings.tab.shortcuts")}
@@ -71,16 +72,20 @@ export const DialogSettings: Component<{
{language.t("settings.section.server")}
+ + + {language.t("settings.tab.workspaces")} + - + {language.t("status.popover.tab.servers")} - + {language.t("settings.providers.title")} - + {language.t("settings.models.title")}
@@ -99,6 +104,9 @@ export const DialogSettings: Component<{ + + + diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 66b8ccd69c2..4a058f26f2a 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -7,7 +7,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" -import { useSettings } from "@/context/settings" +import { type WorkspaceDefaultDestination, useSettings } from "@/context/settings" import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" @@ -85,6 +85,34 @@ const PermissionScopeSetting: Component<{ controller: PermissionScopeController ) } +const WorkspaceDestinationSetting: Component = () => { + const language = useLanguage() + const settings = useSettings() + const options = createMemo((): { value: WorkspaceDefaultDestination; label: string }[] => [ + { value: "last-used", label: language.t("settings.workspaces.default.lastUsed") }, + { value: "local", label: language.t("settings.workspaces.default.local") }, + { value: "new", label: language.t("settings.workspaces.default.new") }, + ]) + + return ( + + option.value === settings.workspaces.defaultDestination())} + value={(option) => option.value} + label={(option) => option.label} + placement="bottom-end" + gutter={6} + onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)} + /> + + ) +} + const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { const language = useLanguage() const options = createMemo(() => @@ -301,6 +329,7 @@ export const SettingsGeneralV2: Component<{ + @@ -363,18 +392,6 @@ export const SettingsGeneralV2: Component<{

{language.t("settings.general.section.advanced")}

- -
- settings.general.setShowFileTree(checked)} - /> -
-
- span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-v2-workspaces-session-time { + flex-shrink: 0; + font-size: 11px; + line-height: 1; + color: var(--v2-text-text-muted); +} + +.settings-v2-workspaces-empty { + display: flex; + align-items: center; + justify-content: center; + padding-block: 48px; + font-size: 13px; + font-weight: 440; + line-height: 1; + color: var(--v2-text-text-muted); +} + +@media (max-width: 639px) { + .settings-v2-workspaces-header { + padding: 24px 20px 20px; + } + + .settings-v2-tab-body.settings-v2-workspaces { + padding: 0 20px 24px; + } + + .settings-v2-workspaces-toolbar, + .settings-v2-workspaces-main { + align-items: flex-start; + } + + .settings-v2-workspaces-toolbar { + flex-wrap: wrap; + } + + .settings-v2-workspaces-toolbar-actions { + width: 100%; + flex-wrap: wrap; + justify-content: space-between; + } + + .settings-v2-workspaces-inventory [data-component="settings-v2-list"] { + padding: 14px; + } + + .settings-v2-workspaces-path { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; + } + + .settings-v2-workspaces-active { + display: none; + } +} + [data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] { width: 480px; max-width: calc(100vw - 32px); diff --git a/packages/app/src/components/settings-v2/workspaces.tsx b/packages/app/src/components/settings-v2/workspaces.tsx new file mode 100644 index 00000000000..affaa66af11 --- /dev/null +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -0,0 +1,471 @@ +import type { Component } from "solid-js" +import { For, Show, createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" +import type { SessionInfo } from "@opencode-ai/client/promise" +import { useQuery } from "@tanstack/solid-query" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { getFilename } from "@opencode-ai/core/util/path" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { showToast } from "@/utils/toast" +import { getRelativeTime } from "@/utils/time" +import { pathKey } from "@/utils/path-key" +import { SettingsListV2 } from "./parts/list" +import { useTabs } from "@/context/tabs" +import { usePlatform } from "@/context/platform" +import { clearWorkspaceTerminals } from "@/context/terminal" +import { ServerConnection } from "@/context/server" +import type { Project } from "@/types" +import { + containsDirectory, + filterWorkspaceInventory, + inspectWorkspaceDeletion, + mergeWorkspaceSessionInventory, + removeWorkspacesSequentially, + sessionsForWorkspace, + type WorkspaceDeleteInspection, + workspaceInventory, +} from "@/utils/workspace" +import { listAllSessions } from "@/utils/session" +import type { ServerScope } from "@/utils/server-scope" +import "./settings-v2.css" + +type Workspace = { + directory: string + project: Project +} + +export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const tabs = useTabs() + const platform = usePlatform() + const [store, setStore] = createStore({ + project: "all", + transaction: undefined as "confirm" | "running" | undefined, + }) + + const workspaces = createMemo(() => workspaceInventory(serverSync().data.project)) + const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length)) + const projectName = (project: Project) => project.name || getFilename(project.worktree) + const projectOptions = createMemo(() => [ + { id: "all", label: language.t("settings.workspaces.filter.all") }, + ...projects().map((project) => ({ id: project.id, label: projectName(project) })), + ]) + const selectedProject = createMemo(() => + store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all", + ) + const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject())) + const captureDeleteContext = () => { + const sdk = serverSDK() + return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } + } + const loadSessions = async (context = captureDeleteContext()) => { + const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" }) + return mergeWorkspaceSessionInventory( + fetched, + Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session), + ) + } + const sessionQuery = useQuery(() => ({ + queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const, + queryFn: () => loadSessions(), + refetchOnMount: "always", + })) + const workspaceSessions = (workspace: Workspace) => { + if (!sessionQuery.isSuccess) return [] + return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) + } + const sessionCount = (workspace: Workspace) => { + if (sessionQuery.isPending) return language.t("session.messages.loading") + if (sessionQuery.isError) return language.t("common.requestFailed") + const count = workspaceSessions(workspace).length + return language.plural("settings.workspaces.sessions", count, { + count, + project: projectName(workspace.project), + }) + } + const lastActive = (workspace: Workspace) => { + const updated = workspaceSessions(workspace)[0]?.time.updated + if (!updated) return undefined + return getRelativeTime(new Date(updated).toISOString(), language.t) + } + const sessionTime = (session: SessionInfo) => { + if (!session.time.updated) return undefined + return getRelativeTime(new Date(session.time.updated).toISOString(), language.t) + } + + const inspect = async (workspace: Workspace, context = captureDeleteContext()) => { + const [working, branch, sessions] = await Promise.all([ + context.sdk.api.vcs.status({ location: { directory: workspace.directory } }), + context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }), + loadSessions(context), + ]) + const result = inspectWorkspaceDeletion({ + workspace: workspace.directory, + activeDirectory: context.activeDirectory, + sessions, + status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean", + }) + return { result, sessions } + } + const inspectionMessage = (result: WorkspaceDeleteInspection) => { + if (result === "active") return language.t("settings.workspaces.delete.blocked.active") + if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked") + if (result === "dirty") return language.t("workspace.status.dirty") + return language.t("workspace.status.clean") + } + const blocked = (result: WorkspaceDeleteInspection) => { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: inspectionMessage(result), + }) + } + + const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => { + const preflight = await inspect(workspace, context) + if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) { + blocked(preflight.result) + return + } + const removed = await context.sdk.api.projectCopy + .remove({ + projectID: workspace.project.id, + location: { directory: workspace.project.worktree }, + directory: workspace.directory, + force: allowDirty, + }) + .then(() => true) + .catch((error) => { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + }) + return false + }) + if (!removed) return + tabs.store.forEach((tab) => { + if (tab.type !== "draft" || tab.server !== context.server) return + const directoryMatches = containsDirectory(workspace.directory, tab.directory) + const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree) + if (!directoryMatches && !worktreeMatches) return + tabs.updateDraft(tab.draftID, { + directory: directoryMatches ? workspace.project.worktree : tab.directory, + worktree: undefined, + }) + }) + clearWorkspaceTerminals( + workspace.directory, + preflight.sessions.map((session) => session.id), + platform, + context.sdk.scope, + ) + context.sync.set( + "project", + produce((draft) => { + const project = draft.find((item) => item.id === workspace.project.id) + if (!project) return + project.sandboxes = (project.sandboxes ?? []).filter( + (directory) => pathKey(directory) !== pathKey(workspace.directory), + ) + }), + ) + } + + let inspectionID = 0 + const releaseConfirmation = () => { + if (store.transaction === "confirm") setStore("transaction", undefined) + } + const transact = async (task: () => Promise) => { + if (store.transaction !== "confirm") return + setStore("transaction", "running") + try { + await task() + } catch (error) { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + }) + } finally { + setStore("transaction", undefined) + } + } + const confirmDelete = (workspace: Workspace) => { + if (store.transaction) return + const context = captureDeleteContext() + const current = ++inspectionID + setStore("transaction", "confirm") + void dialog.push( + () => ( + inspect(workspace, context)} + inspectionMessage={inspectionMessage} + onDelete={() => transact(() => remove(workspace, true, context))} + /> + ), + releaseConfirmation, + ) + } + const removeAll = async (inventory: Workspace[], context: ReturnType) => { + await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context)) + } + const confirmDeleteAll = () => { + if (store.transaction) return + const context = captureDeleteContext() + const inventory = [...filtered()] + const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject() + setStore("transaction", "confirm") + void dialog.push( + () => ( + transact(() => removeAll(inventory, context))} + /> + ), + releaseConfirmation, + ) + } + + return ( + <> +
+

{language.t("settings.tab.workspaces")}

+
+ +
+
+ + {language.plural("settings.workspaces.count", filtered().length)} + +
+ 1}> + option.id === selectedProject())} + value={(option) => option.id} + label={(option) => option.label} + placement="bottom-end" + gutter={6} + onSelect={(option) => option && setStore("project", option.id)} + /> + + 0}> + + } + /> + + + + + {language.t("settings.workspaces.deleteAll")} + + + + + + +
+
+ +
+ 0} + fallback={
{language.t("settings.workspaces.empty")}
} + > + + + {(workspace) => { + const linked = () => workspaceSessions(workspace) + return ( +
+
+
+
+ + + {workspace.directory} + + +
+ {sessionCount(workspace)} +
+
+ + {(value) => ( + + + {value()} + + + )} + + } + onClick={() => confirmDelete(workspace)} + /> +
+
+ 0}> +
+ + {(session) => ( +
+ {session.title} + + {(time) => {time()}} + +
+ )} +
+
+
+
+ ) + }} +
+
+
+
+
+ + ) +} + +function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise }) { + const dialog = useDialog() + const language = useLanguage() + const remove = () => { + const deleting = props.onDelete() + dialog.close() + void deleting + } + + return ( + + + + {language.t("settings.workspaces.deleteAll.confirm", { count: props.count })} +
+ {language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })} + + } + /> +
+ + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("settings.workspaces.deleteAll")} + + +
+ ) +} + +function DialogDeleteWorkspace(props: { + workspace: Workspace + scope: ServerScope + inspectionID: number + inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: SessionInfo[] }> + inspectionMessage: (result: WorkspaceDeleteInspection) => string + onDelete: () => Promise +}) { + const dialog = useDialog() + const language = useLanguage() + const status = useQuery(() => ({ + queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const, + queryFn: props.inspect, + staleTime: 0, + })) + const description = () => { + if (status.isPending) return language.t("workspace.status.checking") + if (status.isError) return language.t("workspace.status.error") + return props.inspectionMessage(status.data?.result ?? "unknown") + } + const remove = () => { + const deleting = props.onDelete() + dialog.close() + void deleting + } + + return ( + + + + {language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })} +
+ + {props.workspace.directory} + +
+ {language.t("settings.workspaces.delete.warning")} +
+ {description()} + + } + /> +
+ + dialog.close()}> + {language.t("common.cancel")} + + + {language.t("workspace.delete.button")} + + +
+ ) +} diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 6972223edf5..2eebb33af6e 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -106,16 +106,26 @@ describe("query keys", () => { }) test("loads projects from the current endpoint", async () => { + const calls: string[] = [] const api = { list: async () => [ { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, ], + directories: async ({ projectID }: { projectID: string }) => { + calls.push(projectID) + return [ + { directory: `/${projectID}` }, + { directory: `/${projectID}/copy`, strategy: "git_worktree" }, + ] + }, } as unknown as ProjectApi const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api)) expect(result.map((project) => project.id)).toEqual(["a", "b"]) + expect(result.map((project) => project.sandboxes)).toEqual([["/a/copy"], ["/b/copy"]]) + expect(calls.toSorted()).toEqual(["a", "b"]) }) test("loads references from the current location-scoped endpoint", async () => { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 5e09ca57cf3..a8e09af1733 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -103,6 +103,7 @@ export const loadGlobalConfigQuery = (scope: ServerScope) => type ProjectApi = { readonly list: () => Promise readonly current: (input?: ProjectCurrentInput) => Promise + readonly directories: ServerApi["project"]["directories"] } type LocationApi = { readonly get: (input?: LocationGetInput) => Promise } @@ -116,10 +117,16 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => queryKey: [scope, "project"], queryFn: () => retry(() => - api.list().then((projects) => { - return projects - .filter((p) => !!p?.id) - .map(normalizeProjectInfo) + api.list().then(async (projects) => { + return (await Promise.all( + projects.filter((project) => !!project?.id).map(async (project) => { + const directories = await api.directories({ projectID: project.id }) + return normalizeProjectInfo({ + ...project, + sandboxes: directories.filter((item) => item.strategy !== undefined).map((item) => item.directory), + }) + }), + )) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() .sort((a, b) => cmp(a.id, b.id)) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index e387391f68b..b151baad1d3 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n" import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" -import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" +import { + pluralCategory, + type UiI18nPluralLookupKey, + type UiI18nPluralKey, + type UiPluralCategory, +} from "@opencode-ai/ui/context/i18n" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" @@ -28,11 +33,13 @@ function localeDirection(locale: Locale): Direction { type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten -type PluralKey = - | UiI18nPluralKey - | "session.question.pending" - | "session.followupDock.summary" - | "session.revertDock.summary" +type AppI18nKey = Extract +type AppI18nPluralKey = { + [Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never +}[AppI18nKey] +type PluralKey = AppI18nPluralKey | UiI18nPluralKey +type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}` +type TranslationKey = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key type Source = { dict: Record } function cookie(locale: Locale) { @@ -189,18 +196,23 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont initialValue: dicts.get(initial) ?? base, }) - const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( - key: keyof Dictionary, + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: TranslationKey, params?: Record, ) => string - const plural = (key: PluralKey, count: number, params?: Record) => { - const category = pluralCategory(intl(), count) + const pluralForm = ( + key: PluralKey, + category: UiPluralCategory, + params?: Record, + ) => { const current = (dict.loading ? base : (dict() ?? base)) as Record const candidate = `${key}.${category}` const fallback = `${key}.other` - return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count }) + return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params) } + const plural = (key: PluralKey, count: number, params?: Record) => + pluralForm(key, pluralCategory(intl(), count), { ...params, count }) const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value] @@ -231,6 +243,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont label, t, plural, + pluralForm, setLocale(next: Locale) { setStore("locale", normalizeLocale(next)) }, diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 41a99f3b67e..72b6628085f 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk" import { useSettings } from "./settings" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" +import type { ServerScope } from "@/utils/server-scope" import { createPromptReady, createPromptSession, @@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) const scope = (): PromptScope => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } - const load = (scope: PromptScope) => { - const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined - if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) + const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => { + const current = settings.general.newLayoutDesigns() + ? selectPromptTab(tabs.store, scope, target?.server ?? serverKey()) + : undefined + if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope) - const key = scopeKey(scope) + const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope) const existing = cache.get(key) if (existing) { cache.delete(key) @@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const entry = createRoot( (dispose) => ({ - value: createPromptSession(serverSDK().scope, scope), + value: createPromptSession(target?.scope ?? serverSDK().scope, scope), dispose, }), owner, @@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => load(scope())) - const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) + const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => + scope ? load(scope, target) : session() const ready = createPromptReady(session) const withSuspense = (cb: () => T): (() => T) => @@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( return { ready, - capture: (scope?: PromptScope) => pick(scope).capture(), + capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => + pick(scope, target).capture(), current: withSuspense(() => session().current()), cursor: withSuspense(() => session().cursor()), dirty: withSuspense(() => session().dirty()), diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 8fe6c99de0c..65c8d645436 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,6 +1,29 @@ import { describe, expect, test } from "bun:test" import type { OpenCodeEvent } from "@opencode-ai/client/promise" -import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { + adaptServerEvent, + applyWorkspaceOperationEvent, + coalesceServerEvents, + enqueueServerEvent, + resumeStreamAfterPageShow, +} from "./server-sdk" +import { ServerScope } from "@/utils/server-scope" +import { WorkspaceOperation } from "@/utils/workspace-operation" + +test("a moved event completes the matching workspace operation", () => { + WorkspaceOperation.start(ServerScope.local, "current", "move", "/workspace") + applyWorkspaceOperationEvent(ServerScope.local, { + directory: "/workspace", + payload: adaptServerEvent({ + id: "moved-current", + created: Date.now(), + type: "session.moved", + durable: { aggregateID: "current", seq: 1, version: 1 }, + data: { sessionID: "current", location: { directory: "/workspace" } }, + } satisfies Extract), + }) + expect(WorkspaceOperation.get(ServerScope.local, "current")?.status).toBe("complete") +}) describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { @@ -72,7 +95,7 @@ describe("current event buffering", () => { type: "session.tool.input.delta", location: { directory: "/repo" }, data: { sessionID: "ses", assistantMessageID: "msg", id, delta }, - } as OpenCodeEvent) + } satisfies Extract) const result = coalesceServerEvents([ { directory: "/repo", payload: current("evt_1", "call_1", "{") }, { directory: "/repo", payload: current("evt_2", "call_1", "}") }, diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index be6e59ff192..cedfc19753e 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -11,6 +11,7 @@ import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { WorkspaceOperation } from "@/utils/workspace-operation" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" @@ -68,6 +69,16 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) { + if (event.payload.current?.type !== "session.moved") return false + WorkspaceOperation.complete( + scope, + event.payload.current.data.sessionID, + event.payload.current.data.location.directory, + ) + return true +} + function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { if ( event?.type === "session.text.delta" || @@ -151,7 +162,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS last = Date.now() const output = coalesceServerEvents(events) batch(() => { - output.forEach((event) => emitter.emit(event.directory, event.payload)) + output.forEach((event) => { + applyWorkspaceOperationEvent(scope, event) + emitter.emit(event.directory, event.payload) + }) }) buffer.length = 0 diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 662e94813f3..fee60efe6a0 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -358,6 +358,23 @@ describe("server session", () => { expect(ctx.store.lineage.peek("child")).toEqual(result) }) + test("applies moved session locations without evicting cached state", () => { + const current = { ...session("child"), location: { directory: "/repo/worktree" } } + const ctx = setup({ child: current }) + ctx.store.remember(current) + + ctx.store.applyV2({ + id: "evt_moved", + created: 2, + type: "session.moved", + durable: { aggregateID: "child", seq: 1, version: 1 }, + location: current.location, + data: { sessionID: "child", location: { directory: "/repo" }, subpath: "packages/app" }, + } satisfies Extract) + + expect(ctx.store.get("child")).toMatchObject({ location: { directory: "/repo" }, subpath: "packages/app" }) + }) + test("loads session content through the server client", async () => { const ctx = setup({ root: session("root") }) diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 903cd4ee628..40bdddee5b3 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -241,7 +241,13 @@ export function createServerSession( const indexProjectedMessage = (message: Message) => { const current = data.session_message[message.sessionID] ?? [] if (current.some((item) => item.id === message.id)) return - setData("session_message", message.sessionID, reconcile([...current, ...projectMessageSource(message)])) + const projected = projectMessageSource(message) + const projectedIDs = new Set(projected.map((item) => item.id)) + setData( + "session_message", + message.sessionID, + reconcile([...current.filter((item) => !projectedIDs.has(item.id)), ...projected]), + ) } const remember = (session: SessionInfo) => { @@ -288,17 +294,19 @@ export function createServerSession( return session } - const resolve = (sessionID: string, options?: { force?: boolean }) => { + const resolve = (sessionID: string, options?: { force?: boolean; signal?: AbortSignal }) => { const cached = data.info[sessionID] if (cached && !options?.force) return Promise.resolve(cached) - const pending = requests.get(sessionID) + const pending = options?.signal ? undefined : requests.get(sessionID) if (pending) return pending const active = generation(sessionID) - const request = sessionApi.get({ sessionID }) + const request = sessionApi.get({ sessionID }, { signal: options?.signal }) const resolved = request.then((result) => { + if (options?.signal?.aborted) return result if (generations.get(sessionID) !== active) return result return remember(result) }) + if (options?.signal) return resolved requests.set(sessionID, resolved) const cleanup = () => { if (requests.get(sessionID) === resolved) requests.delete(sessionID) @@ -1300,6 +1308,7 @@ export function createServerSession( if (items) items.set(input.message.id, { ...input, parts, confirmedParts: [] }) if (!items) optimistic.set(input.sessionID, new Map([[input.message.id, { ...input, parts, confirmedParts: [] }]])) + indexProjectedMessage(input.message) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages)) setData( "part_text_accum_delta", @@ -1333,6 +1342,9 @@ export function createServerSession( ) return } + setData("session_message", input.sessionID, (messages) => + messages?.filter((message) => message.id !== input.messageID), + ) setData("message", input.sessionID, (messages) => messages?.filter((message) => message.id !== input.messageID)) setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index ff27cdac683..d3819c66d4e 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -5,13 +5,21 @@ import type { SessionApi, SessionInfo, SessionListInput, + OpenCodeEvent, } from "@opencode-ai/client/promise" import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" +import { + captureSessionMove, + loadActiveSessionsQuery, + loadMcpQuery, + loadMcpResourcesQuery, + seedActiveSessionStatuses, +} from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" +import { adaptServerEvent } from "./server-sdk" import type { ServerApi } from "@/utils/server" type McpApi = ServerApi["mcp"] @@ -101,6 +109,30 @@ describe("active session query", () => { }) }) +describe("session move normalization", () => { + test("captures and applies current moves from the source placement", () => { + const session = createServerSession({} as ServerApi["session"], {} as ServerApi["message"]) + session.remember(sessionAt("/source")) + const current = { + id: "event-current-move", + created: 10, + type: "session.moved", + durable: { aggregateID: "session", seq: 1, version: 1 }, + location: { directory: "/source" }, + data: { sessionID: "session", location: { directory: "/destination" } }, + } satisfies Extract + const event = adaptServerEvent(current) + + expect(captureSessionMove(event, session.get)).toEqual({ + sessionID: "session", + from: "/source", + }) + session.applyV2(current) + session.apply(event) + expect(session.get("session")?.location.directory).toBe("/destination") + }) +}) + describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { const now = 5_000 @@ -171,6 +203,18 @@ function sessionInfo(id: string) { } as SessionInfo } +function sessionAt(directory: string): SessionInfo { + return { + id: "session", + projectID: "project", + location: { directory }, + title: "Session", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + } +} + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 91fc1496c37..85266541d2f 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -5,7 +5,7 @@ import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import type { InitError } from "../pages/error" -import { ServerSDK } from "./server-sdk" +import { ServerSDK, type ServerEvent } from "./server-sdk" import { bootstrapDirectory, bootstrapGlobal, @@ -55,6 +55,28 @@ import { toggleMcp } from "./global-sync/mcp" import { createServerSession, type ServerSession } from "./server-session" import { usePlatform } from "./platform" +export function captureSessionMove( + event: ServerEvent, + get: (sessionID: string) => { location: { directory: string } } | undefined, +) { + if (event.current?.type !== "session.moved") return + return { + sessionID: event.current.data.sessionID, + from: get(event.current.data.sessionID)?.location.directory, + } +} + +export function shouldRefreshWorkspaceSessions(event: ServerEvent) { + const type = event.current?.type ?? event.type + return ( + type === "session.created" || + type === "session.deleted" || + type === "session.moved" || + type === "session.renamed" || + type === "session.forked" + ) +} + type GlobalStore = { ready: boolean error?: InitError @@ -458,15 +480,51 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) } + const reindexSession = (sessionID: string, from?: string) => { + const next = session.get(sessionID) + if (!next) return + indexSession(next) + if (!from) return + const source = children.children[directoryKey(from)] + if (!source) return + applyDirectoryEvent({ + event: { + type: "session.moved", + properties: { + sessionID, + projectID: next.projectID, + location: next.location, + subpath: next.subpath, + }, + }, + directory: from, + store: source[0], + setStore: source[1], + push: queue.push, + retainedLimit: sessionMeta.get(directoryKey(from))?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + const moved = captureSessionMove(event, session.get) if (event.current) session.applyV2(event.current) session.apply(event) + if (moved) reindexSession(moved.sessionID, moved.from) + if (shouldRefreshWorkspaceSessions(event)) { + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions", + }) + } if (event.current?.type === "session.created") void session .resolve(event.current.data.sessionID, { force: true }) @@ -519,10 +577,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return } - if (event.current?.type === "session.moved") { - const info = session.get(event.current.data.sessionID) - if (info) indexSession(info) - } if (event.current?.type === "session.forked") void session .resolve(event.current.data.sessionID, { force: true }) @@ -639,6 +693,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { updateConfig: updateConfigMutation.mutateAsync, project: projectApi, session, + reindexSession, homeSessions, mcp: { toggle: async (directory: string, name: string) => { diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 6ecee885647..e850d0522c8 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -2,6 +2,10 @@ import { createStore, reconcile } from "solid-js/store" import { createEffect, createMemo } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +export type WorkspaceDefaultDestination = "last-used" | "local" | "new" +export type WorkspaceLastUsed = "local" | "workspace" export interface NotificationSettings { agent: boolean @@ -44,6 +48,10 @@ export interface Settings { permissions: { autoApprove: boolean } + workspaces: { + defaultDestination: WorkspaceDefaultDestination + lastUsed: Record + } notifications: NotificationSettings sounds: SoundSettings } @@ -126,6 +134,10 @@ const defaultSettings: Settings = { permissions: { autoApprove: false, }, + workspaces: { + defaultDestination: "last-used", + lastUsed: {}, + }, notifications: { agent: true, permissions: true, @@ -291,6 +303,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("permissions", "autoApprove", value) }, }, + workspaces: { + defaultDestination: withFallback( + () => store.workspaces?.defaultDestination, + defaultSettings.workspaces.defaultDestination, + ), + setDefaultDestination(value: WorkspaceDefaultDestination) { + setStore("workspaces", (current) => ({ + ...defaultSettings.workspaces, + ...current, + defaultDestination: value, + })) + }, + lastUsed(scope: ServerScope, projectID: string) { + return store.workspaces?.lastUsed?.[ScopedKey.from(scope, projectID)] + }, + setLastUsed(scope: ServerScope, projectID: string, value: WorkspaceLastUsed) { + setStore("workspaces", (current) => ({ + ...defaultSettings.workspaces, + ...current, + lastUsed: { ...current?.lastUsed, [ScopedKey.from(scope, projectID)]: value }, + })) + }, + }, notifications: { agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent), setAgent(value: boolean) { diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index a98879c3e62..17e4cc93828 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -177,6 +177,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ } const actions = { + active() { + if (location.pathname === "/") return + const key = recentKey() + return store.find((tab) => tabKey(tab) === key) + }, addSessionTab: (tab: Omit) => { const next = { type: "session" as const, ...tab } const existing = store.find((item) => tabKey(item) === tabKey(next)) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62d4dc1ae6e..eadd758b0d0 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1123,6 +1123,46 @@ export const dict = { "session.delete.button": "Delete session", "workspace.new": "New workspace", + "common.viewAll": "View all", + "session.new.workspace.local.tooltip": "Use current checkout", + "session.new.workspace.new.tooltip": "Create isolated checkout", + "session.new.workspace.fromBranch": "from {{branch}}", + "session.new.workspace.trigger.tooltip": "Select where to run session", + "session.new.workspace.search.placeholder": "Search workspaces", + "settings.tab.workspaces": "Workspaces", + "settings.workspaces.filter.all": "All projects", + "settings.workspaces.empty": "No workspaces", + "settings.workspaces.count.one": "{{count}} workspace", + "settings.workspaces.count.other": "{{count}} workspaces", + "settings.workspaces.sessions.one": "{{count}} session in {{project}}", + "settings.workspaces.sessions.other": "{{count}} sessions in {{project}}", + "settings.workspaces.lastActiveSession": "Last active session", + "settings.workspaces.deleteAll": "Delete all workspaces", + "settings.workspaces.deleteAll.confirm": "Delete all {{count}} workspaces?", + "settings.workspaces.delete.warning": + "The workspace directory and branch will be permanently removed. Deletion proceeds only if it is clean, inactive, and has no linked sessions.", + "settings.workspaces.deleteAll.warning": + "The {{count}} selected workspaces in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.", + "settings.workspaces.delete.blocked.active": "The active workspace cannot be deleted.", + "settings.workspaces.delete.blocked.linked": "This workspace has linked sessions and cannot be deleted.", + "settings.workspaces.default.title": "Default environment", + "settings.workspaces.default.description": "Choose where new sessions start", + "settings.workspaces.default.lastUsed": "Last used per project", + "settings.workspaces.default.local": "Local directory", + "settings.workspaces.default.new": "New workspace", + "workspace.move.title": "Move to workspace", + "workspace.move.menu.title": "Move session to", + "workspace.move.failed": "Failed to move session", + "workspace.lifecycle.creating": "Creating workspace", + "workspace.lifecycle.created": "Workspace created", + "workspace.lifecycle.starting": "Starting session", + "workspace.onboarding.title": "Isolate sessions with workspaces", + "workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository", + "workspace.lifecycle.moving": "Moving to workspace", + "workspace.lifecycle.set": "Workspace set", + "session.summary.title": "Session details", + "session.summary.noBranch": "No branch", + "session.summary.basedOn": "Based on {{branch}}", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Failed to create workspace", diff --git a/packages/app/src/i18n/parity.test.ts b/packages/app/src/i18n/parity.test.ts index d2f1ed35d14..feb7a0ff275 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -1,5 +1,4 @@ import { describe, expect, test } from "bun:test" -import { desktopNativePluralCategories } from "./desktop-native" const appLocales = [ "ar", @@ -65,15 +64,7 @@ const appLocales = [ "uz", ] as const const desktopLocales = appLocales -const pluralCategories = new Map( - appLocales.map( - (locale) => - [ - locale, - desktopNativePluralCategories(locale).filter((category) => category !== "one" && category !== "other"), - ] as const, - ), -) +const pluralCategories = new Set(["zero", "one", "two", "few", "many", "other"]) const domains = [ { @@ -97,23 +88,19 @@ const domains = [ ] as const describe("i18n parity", () => { - test("non-English locales have every English key and required plural variants", async () => { + test("non-English locales contain only English keys and their plural variants", async () => { for (const domain of domains) { const source = await dictionary(domain.source) + const families = new Set(pluralFamilies(source)) for (const locale of domain.locales) { const target = await dictionary(domain.target(locale)) - const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key)) const extra = Object.keys(target) - .filter((key) => !Object.hasOwn(source, key)) + .filter((key) => !Object.hasOwn(source, key) && !isPluralVariant(key, families)) .sort() - const expected = pluralFamilies(source) - .flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`)) - .sort() - expect({ domain: domain.name, locale, missing, extra }).toEqual({ + expect({ domain: domain.name, locale, extra }).toEqual({ domain: domain.name, locale, - missing: [], - extra: expected, + extra: [], }) } } @@ -127,11 +114,11 @@ describe("i18n parity", () => { const mismatched = Object.keys(source).filter( (key) => Object.hasOwn(target, key) && placeholders(source[key]).join() !== placeholders(target[key]).join(), ) - const pluralMismatched = pluralFamilies(source).flatMap((key) => - (pluralCategories.get(locale) ?? []) - .map((category) => `${key}.${category}`) - .filter((variant) => placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join()), - ) + const pluralMismatched = Object.keys(target).filter((key) => { + const family = pluralFamily(key) + if (!family || !Object.hasOwn(source, `${family}.other`)) return false + return placeholders(source[`${family}.other`]).join() !== placeholders(target[key]).join() + }) expect({ domain: domain.name, locale, mismatched, pluralMismatched }).toEqual({ domain: domain.name, locale, @@ -169,38 +156,6 @@ describe("i18n parity", () => { }) }) -describe("i18n plural parity", () => { - test("locale-specific categories exist and preserve count placeholders", async () => { - for (const domain of domains.slice(0, 2)) { - const source = await dictionary(domain.source) - const families = pluralFamilies(source) - for (const locale of domain.locales) { - const target = await dictionary(domain.target(locale)) - const missing = families.flatMap((key) => - (pluralCategories.get(locale) ?? []) - .map((category) => `${key}.${category}`) - .filter((variant) => !Object.hasOwn(target, variant)), - ) - const mismatched = families.flatMap((key) => - (pluralCategories.get(locale) ?? []) - .map((category) => `${key}.${category}`) - .filter( - (variant) => - Object.hasOwn(target, variant) && - placeholders(source[`${key}.other`]).join() !== placeholders(target[variant]).join(), - ), - ) - expect({ domain: domain.name, locale, missing, mismatched }).toEqual({ - domain: domain.name, - locale, - missing: [], - mismatched: [], - }) - } - } - }) -}) - async function dictionary(file: string) { const module: unknown = await import(file) if (typeof module !== "object" || module === null || !("dict" in module) || !isDictionary(module.dict)) { @@ -220,11 +175,17 @@ function placeholders(value: string) { function pluralFamilies(dictionary: Record) { return Object.keys(dictionary) - .filter( - (key) => - key.endsWith(".one") && - dictionary[key].includes("{{count}}") && - dictionary[`${key.slice(0, -4)}.other`]?.includes("{{count}}"), - ) + .filter((key) => key.endsWith(".one") && Object.hasOwn(dictionary, `${key.slice(0, -4)}.other`)) .map((key) => key.slice(0, -4)) } + +function pluralFamily(key: string) { + const split = key.lastIndexOf(".") + if (split === -1 || !pluralCategories.has(key.slice(split + 1))) return + return key.slice(0, split) +} + +function isPluralVariant(key: string, families: Set) { + const family = pluralFamily(key) + return family !== undefined && families.has(family) +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 78a9e103a7f..495fe7490b6 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -327,4 +327,9 @@ animation-range: 0 0.1px; } } + + body[data-new-layout] [data-slot="session-turn-diffs-header"] { + height: 24px; + padding-block: 0; + } } diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 050d36d2393..fe7f151a36c 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -19,6 +19,7 @@ import { compareSessionTime, displayName, errorMessage, projectForSession } from import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { pathKey } from "@/utils/path-key" import { showToast } from "@/utils/toast" +import { WorkspaceOperation } from "@/utils/workspace-operation" import { Binary } from "@opencode-ai/core/util/binary" import { archiveHomeSession } from "../home-session-archive" import type { HomeController } from "./home-controller" @@ -208,6 +209,7 @@ export function createHomeSessionsController(home: HomeController) { const conn = home.server.focused() const ctx = home.server.focusedContext() if (!conn || !ctx) return + if (WorkspaceOperation.get(ctx.sdk.scope, session.id)?.status === "pending") return const [, setStore] = ctx.sync.child(session.location.directory) await archiveHomeSession({ server: ServerConnection.key(conn), diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 7c7b89f5892..8ceced9e840 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,7 +1,10 @@ import { createPromptProjectController } from "@/components/prompt-project-selector" +import { useSettingsDialog } from "@/components/settings-dialog" import { useTitlebarRightMount } from "@/components/titlebar" import { useSettings } from "@/context/settings" -import { createEffect, createResource } from "solid-js" +import { useTabs, type DraftTab } from "@/context/tabs" +import { useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createResource } from "solid-js" import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view" import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller" @@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands" export default function NewSessionPage() { const settings = useSettings() const rightMount = useTitlebarRightMount() - const workspace = createNewSessionWorkspaceController() + const [search] = useSearchParams<{ draftId?: string }>() + const tabs = useTabs() + const openWorkspaces = useSettingsDialog("workspaces") + const draftTab = createMemo(() => + tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId), + ) + const workspace = createNewSessionWorkspaceController({ + selected: () => draftTab()?.worktree, + setSelected: (worktree) => { + if (search.draftId) tabs.updateDraft(search.draftId, { worktree }) + }, + onViewAll: openWorkspaces, + }) const draft = createNewSessionDraftController({ worktree: workspace.selection.value, resetWorktree: workspace.selection.reset, + onSubmit: workspace.selection.remember, }) const project = createPromptProjectController({ controls: draft.project.controls, diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts index bf22834e48d..7063b9dc971 100644 --- a/packages/app/src/pages/new-session/new-session-draft-controller.ts +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode import { useSessionKey } from "@/pages/session/session-layout" import { useComposerCommands } from "@/pages/session/use-composer-commands" -export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) { +export function createNewSessionDraftController(workspace: { + worktree: () => string + resetWorktree: () => void + onSubmit: () => void +}) { const prompt = usePrompt() const serverSync = useServerSync() const comments = useComments() @@ -36,7 +40,10 @@ export function createNewSessionDraftController(workspace: { worktree: () => str return workspace.worktree() }, onNewSessionWorktreeReset: workspace.resetWorktree, - onSubmit: comments.clear, + onSubmit: () => { + workspace.onSubmit() + comments.clear() + }, }) createEffect(() => { diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 5960e643352..62acdeadf43 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -1,6 +1,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { Icon } from "@opencode-ai/ui/v2/icon" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" import { Show, createMemo, createSignal, type Accessor } from "solid-js" @@ -31,6 +31,15 @@ export function NewSessionView(props: { project: PromptProjectController workspace: NewSessionWorkspaceController }) { + const [onboarding, setOnboarding, , onboardingReady] = persisted( + Persist.global("workspace-onboarding"), + createStore({ used: false }), + ) + const select = (value: string) => { + props.workspace.selection.set(value) + if (value !== "main") setOnboarding("used", true) + } + return (
- + @@ -59,8 +68,10 @@ export function NewSessionView(props: { projectRoot={props.workspace.project.root()} workspaces={props.workspace.project.workspaces()} branch={props.workspace.bar.branch()} - onChange={props.workspace.selection.set} + onboarding={onboardingReady() && !onboarding.used} + onChange={select} onDone={props.input.restoreFocus} + onViewAll={props.workspace.project.openAll} />
@@ -137,7 +148,7 @@ function ProviderTip() { > {language.t("home.providerTip")} setPersistedState("dismissedAt", Date.now())} > - +
diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts index f3fc9b2708d..6453f5b50dc 100644 --- a/packages/app/src/pages/new-session/new-session-workspace-controller.ts +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -1,20 +1,28 @@ -import { createMemo, createSignal } from "solid-js" +import { createMemo } from "solid-js" import { useSDK } from "@/context/sdk" +import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" - -const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" +import { pathKey } from "@/utils/path-key" +import { + isWorkspaceDirectory, + isWorkspaceSelection, + workspaceDefaultSelection, + workspaceDirectories, +} from "@/utils/workspace" export function resolveNewSessionWorktree(input: { enabled: boolean selected?: string directory: string projectWorktree?: string + fallback?: string }) { if (!input.enabled) return "main" if (input.selected) return input.selected if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory - return "main" + return input.fallback ?? "main" } export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) { @@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: { return input.worktreeBranch(input.worktree) ?? input.local } -export function createNewSessionWorkspaceController() { +export function createNewSessionWorkspaceController(input: { + selected: () => string | undefined + setSelected: (worktree: string | undefined) => void + onViewAll: () => void +}) { const sdk = useSDK() const sync = useSync() + const serverSDK = useServerSDK() const serverSync = useServerSync() - const [worktree, setWorktree] = createSignal() - const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + const settings = useSettings() + const visible = createMemo(() => sync().project?.vcs === "git") + const selected = createMemo(() => { + const project = sync().project + const worktree = input.selected() + if (!project || !worktree) return + return isWorkspaceSelection(project, worktree) ? worktree : undefined + }) + const fallback = createMemo(() => { + const project = sync().project + if (!project) return "main" + return workspaceDefaultSelection( + settings.workspaces.defaultDestination(), + settings.workspaces.lastUsed(serverSDK().scope, project.id), + ) + }) const value = createMemo(() => resolveNewSessionWorktree({ enabled: visible(), - selected: worktree(), + selected: selected(), directory: sdk().directory, projectWorktree: sync().project?.worktree, + fallback: fallback(), }), ) const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) @@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() { worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch, }), ) + const remember = (worktree = value()) => { + const project = sync().project + if (!project) return + const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree) + settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace") + } return { selection: { value, - reset: () => setWorktree(), - set: (worktree: string) => - setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)), + workspace: createMemo(() => { + const project = sync().project + const current = value() + return current === "create" || (!!project && isWorkspaceDirectory(project, current)) + }), + reset: () => input.setSelected(undefined), + remember, + set: (worktree: string) => { + input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)) + remember(worktree) + }, }, project: { root: projectRoot, - workspaces: () => sync().project?.sandboxes ?? [], + workspaces: () => { + const project = sync().project + return project ? workspaceDirectories(project) : [] + }, git: () => sync().project?.vcs === "git", + openAll: input.onViewAll, }, bar: { visible, diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 9baee93b5e1..1a8eaaf58a9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -38,6 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" +import { isWorkspaceDirectory } from "@/utils/workspace" import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" @@ -101,6 +102,7 @@ import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" +import { canMoveSessionToWorkspace, WorkspaceOperation } from "@/utils/workspace-operation" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionLineage } from "./session/session-lineage" @@ -521,6 +523,9 @@ export default function Page() { if (!controller.layout.view().reviewPanel.opened()) controller.layout.view().reviewPanel.open() } + const workspaceSession = createMemo(() => + isWorkspaceDirectory(sync().project, controller.data.info()?.location.directory ?? sdk().directory), + ) const timeline = createTimelineModel({ session: controller }) const historyLoading = timeline.history.loading const historyMore = timeline.history.more @@ -575,6 +580,7 @@ export default function Page() { const [store, setStore] = createStore({ ...sessionViewState(), newSessionWorktree: "main", + sessionDetailsOpen: false, deferRender: false, }) @@ -677,6 +683,19 @@ export default function Page() { : skipToken, } }) + const sessionDetailsQuery = createQuery(() => ({ + queryKey: [...vcsKey(), "git"] as const, + enabled: store.sessionDetailsOpen && sync().project?.vcs === "git", + queryFn: () => + sdk() + .api.vcs.diff({ location: { directory: sdk().directory }, mode: "working" }) + .then((result) => result.data) + .catch((error) => { + console.debug("[session-review] failed to load session details diff", { error }) + return [] + }), + })) + const sessionDetailsDiffs = () => (sessionDetailsQuery.isFetched ? (sessionDetailsQuery.data ?? []) : []) const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) createEffect( on( @@ -1671,6 +1690,8 @@ export default function Page() { } const busy = (sessionID: string) => sync().data.session_working(sessionID) + const workspaceOperationPending = (sessionID: string) => + WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending" const queuedFollowups = createMemo(() => { const id = controller.identity.params.id @@ -1684,8 +1705,20 @@ export default function Page() { return followup.edit[id] }) + const workspaceMoveEligible = createMemo(() => { + const id = controller.identity.params.id + if (!id) return false + return canMoveSessionToWorkspace({ + queued: followup.items[id]?.length ?? 0, + failed: !!followup.failed[id], + paused: !!followup.paused[id], + editing: !!followup.edit[id], + }) + }) + const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + if (workspaceOperationPending(input.sessionID)) return const owner = controller.ownership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1695,6 +1728,7 @@ export default function Page() { const ok = await sendFollowupDraft({ api: sdk().api.session, + scope: serverSDK().scope, sync: sync(), serverSync: serverSync(), session: () => sync().session.get(input.sessionID), @@ -1763,6 +1797,7 @@ export default function Page() { const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => { if (sync().session.get(sessionID)?.parentID) return Promise.resolve() + if (workspaceOperationPending(sessionID)) return Promise.resolve() const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id) if (!item) return Promise.resolve() if (followupBusy(sessionID)) return Promise.resolve() @@ -1802,6 +1837,7 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { + if (workspaceOperationPending(input.sessionID)) return const api = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert @@ -1824,6 +1860,7 @@ export default function Page() { mutationFn: async (id: string) => { const sessionID = controller.identity.params.id if (!sessionID) return + if (workspaceOperationPending(sessionID)) return const api = sdk().api.session const target = sync() @@ -1853,7 +1890,10 @@ export default function Page() { }, })) - const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) + const reverting = createMemo(() => { + const id = controller.identity.params.id + return revertMutation.isPending || restoreMutation.isPending || (!!id && workspaceOperationPending(id)) + }) const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined)) const revert = (input: { sessionID: string; messageID: string }) => { @@ -1913,6 +1953,7 @@ export default function Page() { if (controller.data.isChild()) return if (composer.blocked()) return if (controller.data.working()) return + if (workspaceOperationPending(sessionID)) return void sendFollowup(sessionID, item.id) }) @@ -2020,7 +2061,7 @@ export default function Page() { > {hasReview() ? language.t("session.review.filesChanged", { count: reviewCount() }) - : language.t("session.review.change.other")} + : language.plural("session.review.change", 0)} @@ -2088,6 +2129,9 @@ export default function Page() { if (root) scheduleScrollState(root) }} userMessages={visibleUserMessages()} + diffs={sessionDetailsDiffs} + workspaceMoveEligible={workspaceMoveEligible()} + onSummaryOpenChange={(open) => setStore("sessionDetailsOpen", open)} setHistoryAnchor={(handlers) => { captureHistoryAnchor = handlers.capture restoreHistoryAnchor = handlers.restore @@ -2215,7 +2259,13 @@ export default function Page() { setFollowup("paused", id, true) }, }) - return + return ( + + ) }} } diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index a9b0070bc02..7897ecb0392 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -91,7 +91,7 @@ export function createPromptProjectControls() { const target = global.ensureServerCtx(conn) target.projects.open(worktree) target.projects.touch(worktree) - tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree }) + tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree, worktree: undefined }) return } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 0bf642ea5a0..1e687453163 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -782,10 +782,7 @@ export function SessionSidePanel(props: { when={settings.general.newLayoutDesigns()} fallback={ <> - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} + {props.reviewCount()} {language.plural("session.review.change", props.reviewCount())} } > diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index c281c2ecc88..bd3195bafd6 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -33,6 +33,8 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { InlineInput } from "@opencode-ai/ui/inline-input" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SessionRetry } from "@opencode-ai/session-ui/session-retry" @@ -41,7 +43,7 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header" import { TextField } from "@opencode-ai/ui/text-field" import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" -import type { AssistantMessage, ToolPart, UserMessage } from "@/types" +import type { AssistantMessage, Project, ToolPart, UserMessage } from "@/types" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/session-ui/session-diff" @@ -49,11 +51,21 @@ import { useFileComponent } from "@opencode-ai/ui/context/file" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useCommand } from "@/context/command" import { scheduleConnectedMeasure } from "./measure" import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller" +import { isWorkspaceDirectory } from "@/utils/workspace" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { SessionWorkspaceMenu } from "@/components/session-workspace-menu" +import { getProjectAvatarVariant } from "@/context/layout" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] @@ -108,7 +120,7 @@ function TimelineThinkingRow(props: { reasoningHeading?: string; showReasoningSu ) } -function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { +function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[]; action?: JSX.Element }) { const language = useLanguage() const maxFiles = 10 const [state, setState] = createStore({ @@ -136,6 +148,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")} + {props.action}