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 35980499787..1bc2e755949 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -99,13 +99,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, { protocol: "v2" }) - 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..80fbd5b7aed --- /dev/null +++ b/packages/app/e2e/regression/workspaces.spec.ts @@ -0,0 +1,365 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test, type Page } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" +import { installSseTransport } from "../utils/sse-transport" + +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const root = "C:/OpenCode/WorkspaceProject" +const workspace = "C:/OpenCode/worktree/project/feature" +const createdWorkspace = "C:/OpenCode/worktree/project/quick-contrast-fix" +const project = { + id: "proj_workspaces", + worktree: root, + vcs: "git" as const, + name: "workspace-project", + time: { created: 1, updated: 1 }, + sandboxes: [workspace], +} +const provider = { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test model", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, +} +const diff = { + file: "src/workspace.ts", + additions: 3, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const workspace = false\n+export const workspace = true", +} + +function userMessage(sessionID: string, id: string, text: string, withDiff = false) { + return { + info: { + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: "opencode", modelID: "test" }, + ...(withDiff ? { summary: { diffs: [diff] } } : {}), + }, + parts: [{ id: `prt_${id}`, sessionID, messageID: id, type: "text", text }], + } +} + +async function init(page: Page, tab: Record) { + await page.addInitScript( + ({ root, server, tab }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }), + ) + localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }])) + }, + { root, server, tab }, + ) +} + +test("selects an existing workspace from the start menu", async ({ page }) => { + const draftID = "draft_workspaces" + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await expectAppVisible(page.locator('[data-component="prompt-input"]')) + + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: /Workspace/ }).hover() + await page.getByRole("menuitem", { name: "feature" }).click() + await expect(page.getByRole("button", { name: /feature/ })).toBeVisible() +}) + +test("lists and manually deletes workspaces from settings", async ({ page }) => { + const draftID = "draft_workspace_settings" + const cleanWorkspace = `${workspace}-clean` + const inventory = { ...project, sandboxes: [cleanWorkspace] } + + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project: inventory, + provider, + sessions: [], + pageMessages: () => ({ items: [] }), + }) + let releaseSessions = () => {} + const sessionsReady = new Promise((resolve) => { + releaseSessions = resolve + }) + await page.route("**/session?**", async (route) => { + const url = new URL(route.request().url()) + if (route.request().method() !== "GET" || url.pathname !== "/session" || url.searchParams.get("limit") !== "1000") + return route.fallback() + await sessionsReady + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "[]", + }) + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" }, + }) + return + } + if (route.request().method() !== "DELETE") return route.fallback() + await transport.send({ + directory: "global", + payload: { + id: "evt_workspace_deleted_settings", + type: "project.updated", + properties: { ...inventory, sandboxes: [] }, + }, + }) + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "true", + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await transport.waitForConnection() + await expectAppVisible(page.locator('[data-component="prompt-input"]')) + + const app = page.locator('[data-component="session-new-design"]') + const appNode = await app.elementHandle() + await page.getByRole("button", { name: /local|new workspace/i }).click() + const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ }) + if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover() + await page.getByRole("menuitem", { name: "View all" }).click() + + const settings = page.locator(".settings-v2-dialog") + await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected") + expect(await appNode?.evaluate((node) => node.isConnected)).toBe(true) + releaseSessions() + await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible() + + await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click() + const confirmation = page + .locator('[data-component="dialog-v2"]') + .filter({ hasText: 'Delete workspace "feature-clean"?' }) + const removed = page.waitForRequest( + (request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree", + ) + await confirmation.getByRole("button", { name: "Delete workspace" }).click() + const request = await removed + expect(new URL(request.url()).searchParams.get("directory")).toBe(root) + expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace }) + await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0) +}) + +test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => { + const draftID = "draft_workspace_submit" + const sessionID = "ses_workspace_submit" + const session = { + id: sessionID, + slug: "workspace-submit", + projectID: project.id, + directory: createdWorkspace, + title: "New session", + version: "dev", + time: { created: 1, updated: 2 }, + } + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [session], + pageMessages: () => ({ items: [] }), + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + if (route.request().method() !== "POST") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }), + }) + }) + await page.route("**/session**", async (route) => { + const url = new URL(route.request().url()) + if (url.pathname !== "/session") return route.fallback() + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + if (route.request().method() !== "POST") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify(session), + }) + }) + await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*" }, + }) + }) + await init(page, { type: "draft", draftID, directory: root }) + + await page.goto(`/new-session?draftId=${draftID}`) + await transport.waitForConnection() + await page.getByRole("button", { name: /^local$/i }).click() + await page.getByRole("menuitem", { name: "New workspace" }).click() + const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]') + await editor.fill("Build workspace support") + await page.locator('[data-action="prompt-submit"]').click() + + const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]') + await expect(lifecycle).toContainText("Creating workspace") + const sent = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`, + ) + await transport.send({ + directory: createdWorkspace, + payload: { + id: "evt_submit_ready", + type: "worktree.ready", + properties: { name: "quick-contrast-fix" }, + }, + }) + await sent + await expect(lifecycle).toContainText("Workspace created") +}) + +test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({ + page, +}) => { + const sessionID = "ses_workspace_move_new" + const messageID = "msg_workspace_move_new" + const session = { + id: sessionID, + slug: "workspace-move-new", + projectID: project.id, + directory: root, + title: "Create a workspace", + version: "dev", + time: { created: 1, updated: 2 }, + } + const transport = await installSseTransport<{ directory: string; payload: Record }>(page, { server }) + await mockOpenCodeServer(page, { + directory: root, + project, + provider, + sessions: [session], + pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }), + vcsDiff: [diff], + }) + await page.route("**/experimental/worktree**", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + if (route.request().method() !== "POST") return route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }), + }) + }) + await page.route("**/experimental/control-plane/move-session", async (route) => { + if (route.request().method() === "OPTIONS") { + await route.fulfill({ + status: 204, + headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" }, + }) + return + } + session.directory = createdWorkspace + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "access-control-allow-origin": "*" }, + body: "null", + }) + }) + await init(page, { type: "session", sessionId: sessionID }) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await transport.waitForConnection() + await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click() + const panel = page.locator('[data-component="session-summary-panel"]') + await panel.getByRole("button", { name: "Local repository" }).click() + await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible() + await page.getByRole("menuitem", { name: "New workspace" }).click() + + const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]') + await expect(lifecycle).toContainText("Creating workspace") + const moved = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session", + ) + await transport.send({ + directory: createdWorkspace, + payload: { + id: "evt_worktree_ready", + type: "worktree.ready", + properties: { name: "quick-contrast-fix" }, + }, + }) + expect((await moved).postDataJSON()).toEqual({ + sessionID, + destination: { directory: createdWorkspace }, + moveChanges: true, + }) + await transport.send({ + directory: createdWorkspace, + payload: { + id: "evt_workspace_created", + type: "session.next.moved", + properties: { + timestamp: Date.now(), + sessionID, + location: { directory: createdWorkspace }, + subdirectory: "", + }, + }, + }) + await expect(lifecycle).toContainText("Workspace created") +}) diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index a0a20a2e17f..a1e32e28daf 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) @@ -248,12 +264,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 @@ -265,8 +284,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({ @@ -304,8 +323,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 0c950871a33..996f912a73b 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" @@ -235,7 +236,13 @@ function UiI18nBridge(props: ParentProps) { const language = useLanguage() return ( {props.children} diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index e12b9e2145e..49f9b6f9060 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -10,9 +10,6 @@ import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4" import homeImage from "@/assets/help/home.png" import tabsImage from "@/assets/help/tabs.png" -// TODO: wire to changelog / seen-state when available -const showPopover = () => true - // can remove this after the tabs rollout has been out for a while export function TabsInfoPopup() { const settings = useSettings() @@ -134,7 +131,6 @@ export function TabsInfoPopup() {

{language.t("help.tabs.home")}

{language.t("help.tabs.persistence")}

-

{language.t("help.tabs.worktrees")}

diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 481b7922cf3..aafbb0c7c35 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,24 +22,35 @@ 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[] = [] const sentCommands: unknown[] = [] -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 = {} let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ @@ -67,19 +78,21 @@ 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) return { id: `session-${createdSessions.length}`, projectID: "project", @@ -102,6 +115,7 @@ const clientFor = (directory: string) => { }, shell: async (input: { sessionID: string; id?: string; command: string }) => { sentShell.push(input) + sentShellDirectories.push(directory) }, }, }, @@ -110,7 +124,16 @@ const clientFor = (directory: string) => { abort: async () => ({ data: undefined }), }, worktree: { - create: async () => ({ data: { directory: `${directory}/new` } }), + 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 { data: { directory: worktreeDirectory } } + }, }, } } @@ -127,13 +150,13 @@ beforeAll(async () => { mock.module("@opencode-ai/sdk/v2/client", () => ({ createOpencodeClient: (input: { directory: string }) => { - createdClients.push(input.directory) return clientFor(input.directory) }, })) mock.module("@opencode-ai/ui/toast", () => ({ Toast: { Region: () => null }, + toaster: { create: () => undefined, show: () => undefined, dismiss: () => undefined }, showToast: () => 0, })) @@ -151,20 +174,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", () => ({ @@ -173,7 +189,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 }) }, @@ -194,72 +213,75 @@ 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", client: rootClient, api: rootClient.api, url: "http://localhost:4096", + createApi: (directory: string) => clientFor(directory).api, createClient(opts: any) { return clientFor(opts.directory) }, - } - 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 }, + 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", () => ({ @@ -279,198 +301,153 @@ 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 - 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(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]) + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() - expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - 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) + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(worktreeCreates).toBe(2) + expect(createdSessions).toEqual([worktreeDirectory]) + expect(sentPrompts).toEqual([worktreeDirectory]) + expect(resets).toBe(1) }) - test("keeps 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("includes the selected variant on optimistic prompts", async () => { - params = { id: "session-1" } + params.id = "session-1" variant = "high" - 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, - onSubmit: () => undefined, }) - const event = { preventDefault: () => undefined } as unknown as Event - await submit.handleSubmit(event) await Bun.sleep(0) @@ -500,24 +477,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([ { @@ -533,66 +498,43 @@ 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("waits for a new workspace before sending an initial shell", 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) + expect(sentShell).toEqual([]) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending") - 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]) + Worktree.ready(ServerScope.local, worktreeDirectory) + await settle() + + expect(sentShellDirectories).toEqual([worktreeDirectory]) + expect(sentShell[0]).toMatchObject({ + sessionID: "session-1", + command: "ls", + agent: "agent", + model: { providerID: "provider", modelID: "model" }, + }) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("complete") + }) + + test("settles a pending workspace operation when the initial prompt is aborted", async () => { + selected = "create" + const submit = makeSubmit() + + await submit.handleSubmit(event) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending") + params = { id: "session-1" } + + await submit.abort() + await settle() + + expect(sentPrompts).toEqual([]) + expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({ + status: "failed", + }) }) }) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index c82c3a43993..4436be1aa1e 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -15,10 +15,12 @@ import { useSDK, type DirectorySDK } from "@/context/sdk" import { useSync, type DirectorySync } from "@/context/sync" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { WORKSPACE_PREPARATION_TIMEOUT_MS, workspaceRequestWithTimeout } from "@/utils/workspace-request" import { buildRequestParts } from "./build-request-parts" import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" -import { ScopedKey } from "@/utils/server-scope" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" import { normalizeSessionInfo } from "@/utils/session" import { Event } from "@opencode-ai/schema/event" @@ -27,9 +29,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 @@ -43,6 +49,7 @@ export type FollowupDraft = { type FollowupSendInput = { api: DirectorySDK["api"]["session"] + scope: ServerScope serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -56,6 +63,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 = () => { @@ -229,6 +238,7 @@ type PromptSubmitInput = { onAbort?: () => void onSubmit?: () => void model?: ModelSelection + worktreeRequestTimeoutMs?: number } export function createPromptSubmit(input: PromptSubmitInput) { @@ -244,7 +254,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 @@ -257,18 +268,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() } @@ -300,9 +316,9 @@ export function createPromptSubmit(input: PromptSubmitInput) { } } - const seed = (dir: string, info: Session) => { - serverSync().session.remember(info) - const [, setStore] = serverSync().child(dir) + const seed = (target: ServerSync, dir: string, info: Session) => { + target.session.remember(info) + const [, setStore] = target.child(dir) setStore("session", (list: Session[]) => { const result = Binary.search(list, info.id, (item) => item.id) const next = [...list] @@ -334,6 +350,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() @@ -347,295 +364,377 @@ 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 - let client = sdk().client + try { + input.addToHistory(currentPrompt, mode) + input.resetHistoryNavigation() - if (isNewSession) { - if (worktreeSelection === "create") { - const createdWorktree = await client.worktree - .create({ directory: projectDirectory }) - .then((x) => x.data) - .catch((err) => { + let sessionDirectory = projectDirectory + let client = submissionSDK.client + let api = submissionSDK.api.session + + if (isNewSession) { + if (worktreeSelection === "create") { + const createdWorktree = await workspaceRequestWithTimeout( + (signal) => client.worktree.create({ directory: projectDirectory }, { signal }), + language.t("prompt.toast.worktreeCreateFailed.title"), + input.worktreeRequestTimeoutMs ?? WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .then((x) => x.data) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: errorMessage(err), + }) + return undefined + }) + + if (!createdWorktree?.directory) { showToast({ title: language.t("prompt.toast.worktreeCreateFailed.title"), + description: language.t("common.requestFailed"), + }) + return + } + WorktreeState.pending(submissionScope, createdWorktree.directory) + sessionDirectory = createdWorktree.directory + } + + if (worktreeSelection !== "main" && worktreeSelection !== "create") { + sessionDirectory = worktreeSelection + } + + if (sessionDirectory !== projectDirectory) { + client = submissionSDK.createClient({ + directory: sessionDirectory, + throwOnError: true, + }) + api = submissionSDK.createApi(sessionDirectory).session + submissionServerSync.child(sessionDirectory) + } + } + + let session = currentSession + if (!session && isNewSession) { + const created = await submissionSDK.api.session + .create({ + agent: currentAgent.name, + model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, + location: { directory: sessionDirectory }, + }) + .then(normalizeSessionInfo) + .catch((err) => { + showToast({ + title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(err), }) return undefined }) - - if (!createdWorktree?.directory) { - showToast({ - title: language.t("prompt.toast.worktreeCreateFailed.title"), - description: language.t("common.requestFailed"), + 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 } - WorktreeState.pending(sdk().scope, createdWorktree.directory) - sessionDirectory = createdWorktree.directory + } + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return } - if (worktreeSelection !== "main" && worktreeSelection !== "create") { - sessionDirectory = worktreeSelection + 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, } - if (sessionDirectory !== projectDirectory) { - client = sdk().createClient({ - directory: sessionDirectory, - throwOnError: true, - }) - serverSync().child(sessionDirectory) + const clearInput = () => { + submission.clear() + input.setMode("normal") + input.setPopover(null) } - input.onNewSessionWorktreeReset?.() - } - - let session = input.info() - if (!session && isNewSession) { - const created = await sdk() - .api.session.create({ - agent: currentAgent.name, - model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, - location: { directory: sessionDirectory }, - }) - .then(normalizeSessionInfo) - .catch((err) => { - showToast({ - title: language.t("prompt.toast.sessionCreateFailed.title"), - description: errorMessage(err), - }) - return undefined - }) - if (created) { - seed(sessionDirectory, created) - session = created - await startTransition(() => { - if (!session) return - if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id, { - agent: currentAgent.name, - model: { providerID: currentModel.provider.id, modelID: currentModel.id }, - variant: variant ?? null, - }) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + 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 (!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, - } + if (!isNewSession && mode === "normal" && input.shouldQueue?.()) { + input.onQueue?.(draft) + clearContext(submission.target()) + clearInput() + return + } - const clearInput = () => { - submission.clear() - input.setMode("normal") - input.setPopover(null) - } + const startWorkspaceOperation = (messageID: string) => { + if (!isNewSession) return + if (worktreeSelection !== "main" && worktreeSelection !== "create" && sessionDirectory !== projectRoot) { + WorkspaceOperation.start(submissionScope, session.id, "move", sessionDirectory, messageID) + WorkspaceOperation.complete(submissionScope, session.id) + } + if (worktreeSelection !== "create") return + const worktree = WorktreeState.get(submissionScope, sessionDirectory) + WorkspaceOperation.start(submissionScope, session.id, "create", sessionDirectory, messageID) + if (worktree?.status === "ready") WorkspaceOperation.complete(submissionScope, session.id) + if (worktree?.status === "failed") WorkspaceOperation.fail(submissionScope, session.id) + } - const 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 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 (!isNewSession && mode === "normal" && input.shouldQueue?.()) { - input.onQueue?.(draft) - clearContext(submission.target()) - clearInput() - return - } + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "busy" }) + } - input.onSubmit?.() - - if (mode === "shell") { - clearInput() - const eventID = Event.ID.create() - sdk() - .api.session.shell({ + 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, - agent, - model, + 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 api.shell({ + sessionID: session.id, + id: eventID, + command: text, + agent, + model, + }) }) .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 api.command({ + sessionID: session.id, + id: messageID, + command: commandName, + arguments: args.join(" "), + agent, + model: { id: model.modelID, providerID: model.providerID, variant }, + files: await Promise.all( + images.map(async (attachment) => ({ + uri: await blobDataUrl(attachment.blob, attachment.mime), + name: attachment.filename, + })), + ), + }) + }) + .catch((err) => { + submissionServerSync.session.set("session_status", session.id, { type: "idle" }) + showToast({ + title: language.t("prompt.toast.commandSendFailed.title"), + description: formatServerError(err, language.t, language.t("common.requestFailed")), + }) + restoreInput() + }) + return + } } - 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, + scope: submissionScope, + sync: submissionSync, + serverSync: submissionServerSync, + draft, + messageID, + optimisticBusy: sessionDirectory === projectDirectory, + before: () => waitForWorktree(cleanup), + }).catch((err) => { + pending.delete(pendingKey(submissionScope, session.id)) + if (sessionDirectory === projectDirectory) { + submissionSync.set("session_status", session.id, { type: "idle" }) } - 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(), - 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..4d89c552808 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,217 @@ 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..25ec19df27a --- /dev/null +++ b/packages/app/src/components/session-workspace-menu.tsx @@ -0,0 +1,196 @@ +import type { Project } from "@opencode-ai/sdk/v2/client" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { getFilename } from "@opencode-ai/core/util/path" +import { createStore } from "solid-js/store" +import { For, Show, type ComponentProps, type JSX } from "solid-js" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { useSettingsDialog } from "@/components/settings-dialog" +import { pathKey } from "@/utils/path-key" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { showToast } from "@/utils/toast" +import type { ServerScope } from "@/utils/server-scope" +import { workspaceDirectories } from "@/utils/workspace" +import { + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + WORKSPACE_PREPARATION_TIMEOUT_MS, + workspaceRequestWithTimeout, +} from "@/utils/workspace-request" + +export function SessionWorkspaceMenu(props: { + eligible?: boolean + sessionID: string + project: Project + directory: string + messageID?: string + placement?: ComponentProps["placement"] + gutter?: number + class?: string + contentClass?: string + children: JSX.Element + onOpenChange?: (open: boolean) => void +}) { + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const openWorkspaces = useSettingsDialog("workspaces") + const [store, setStore] = createStore({ selected: undefined as string | undefined }) + const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending" + const blocked = () => + props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID) + const workspaces = () => + workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory)) + + const fail = (scope: ServerScope, sessionID: string, message: string) => { + if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return + WorkspaceOperation.fail(scope, sessionID) + showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message }) + } + const move = async (selection: "create" | string) => { + if (store.selected || blocked()) return + const sdk = serverSDK() + const sync = serverSync() + const scope = sdk.scope + const sessionID = props.sessionID + const messageID = props.messageID + const root = props.project.worktree + const source = props.directory + setStore("selected", selection) + + try { + const destination = + selection === "create" + ? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), { + createFailed: language.t("prompt.toast.worktreeCreateFailed.title"), + stillPreparing: language.t("workspace.error.stillPreparing"), + }) + : selection + if (!destination) return + + WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID) + if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed")) + await workspaceRequestWithTimeout( + (signal) => + sdk.client.experimental.controlPlane.moveSession( + { + sessionID, + destination: { directory: destination }, + moveChanges: true, + }, + { signal }, + ), + language.t("workspace.move.failed"), + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + const session = await workspaceRequestWithTimeout( + (signal) => sync.session.resolve(sessionID, { force: true, signal }), + language.t("workspace.move.failed"), + WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS, + ) + if (!session || pathKey(session.directory) !== pathKey(destination)) + throw new Error(language.t("workspace.move.failed")) + WorkspaceOperation.complete(scope, sessionID, destination) + sync.reindexSession(sessionID, source) + } catch (error) { + fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")) + } finally { + setStore("selected", undefined) + } + } + + return ( + + + {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( + root: string, + sessionID: string, + messageID: string | undefined, + serverSDK: ReturnType>, + fail: (message: string) => void, + messages: { createFailed: string; stillPreparing: string }, +) { + WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID) + const created = await workspaceRequestWithTimeout( + (signal) => serverSDK.client.worktree.create({ directory: root }, { signal }), + messages.createFailed, + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + .then((result) => result.data) + .catch((error) => { + fail(error instanceof Error ? error.message : messages.createFailed) + return undefined + }) + if (!created?.directory) return + WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID) + Worktree.pending(serverSDK.scope, created.directory) + const timer = { id: undefined as ReturnType | undefined } + const timeout = new Promise>((resolve) => { + timer.id = setTimeout( + () => resolve({ status: "failed", message: messages.stillPreparing }), + WORKSPACE_PREPARATION_TIMEOUT_MS, + ) + }) + const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => { + if (timer.id) clearTimeout(timer.id) + }) + if (!ready || ready.status === "failed") { + fail(ready?.message ?? messages.createFailed) + return + } + return created.directory +} 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 4116f4a6208..df7c37b6845 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -10,8 +10,8 @@ import { SettingsProvidersV2 } from "./providers" import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" +import { SettingsWorkspacesV2 } from "./workspaces" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useLayout } from "@/context/layout" import { useTabs } from "@/context/tabs" import { useServerSync } from "@/context/server-sync" @@ -22,18 +22,16 @@ export const DialogSettings: Component<{ const language = useLanguage() const platform = usePlatform() const dialog = useDialog() - const layout = useLayout() const tabs = useTabs() const serverSync = useServerSync() const [tab, setTab] = createSignal(props.defaultValue ?? "general") const directory = createMemo(() => { - const route = layout.route() - if (route.type === "dir-new-sesssion") return route.dir - if (route.type === "draft") { - const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) - return draft?.type === "draft" ? draft.directory : undefined + const active = tabs.active() + if (active?.type === "draft") return active.directory + if (active?.type === "session") { + const session = serverSync().session.get(active.sessionId) + if (session) return session.directory } - if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory return undefined }) @@ -58,11 +56,11 @@ export const DialogSettings: Component<{ {language.t("settings.section.desktop")}
- + {language.t("settings.tab.general")} - + {language.t("settings.tab.shortcuts")}
@@ -71,16 +69,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 +101,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 b41da107ed6..8e131f3a41d 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -8,7 +8,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" -import { 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" @@ -87,6 +87,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(() => @@ -329,6 +357,7 @@ export const SettingsGeneralV2: Component<{ + @@ -391,18 +420,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..3202fe5a024 --- /dev/null +++ b/packages/app/src/components/settings-v2/workspaces.tsx @@ -0,0 +1,475 @@ +import type { Component } from "solid-js" +import { For, Show, createMemo } from "solid-js" +import { createStore, produce } from "solid-js/store" +import type { Project, Session } from "@opencode-ai/sdk/v2/client" +import { useQuery } from "@tanstack/solid-query" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Dialog, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode-ai/ui/v2/dialog-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { getFilename } from "@opencode-ai/core/util/path" +import { useLanguage } from "@/context/language" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { showToast } from "@/utils/toast" +import { getRelativeTime } from "@/utils/time" +import { pathKey } from "@/utils/path-key" +import { SettingsListV2 } from "./parts/list" +import { useTabs } from "@/context/tabs" +import { usePlatform } from "@/context/platform" +import { clearWorkspaceTerminals } from "@/context/terminal" +import { ServerConnection } from "@/context/server" +import { + containsDirectory, + filterWorkspaceInventory, + inspectWorkspaceDeletion, + mergeWorkspaceSessionInventory, + removeWorkspacesSequentially, + sessionsForWorkspace, + type WorkspaceDeleteInspection, + workspaceInventory, +} from "@/utils/workspace" +import { listAllSessions, normalizeSessionInfo } from "@/utils/session" +import type { ServerScope } from "@/utils/server-scope" +import "./settings-v2.css" + +type Workspace = { + directory: string + project: Project +} + +export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (props) => { + const dialog = useDialog() + const language = useLanguage() + const serverSDK = useServerSDK() + const serverSync = useServerSync() + const tabs = useTabs() + const platform = usePlatform() + const [store, setStore] = createStore({ + project: "all", + transaction: undefined as "confirm" | "running" | undefined, + }) + + const workspaces = createMemo(() => workspaceInventory(serverSync().data.project)) + const projects = createMemo(() => serverSync().data.project.filter((project) => project.sandboxes?.length)) + const projectName = (project: Project) => project.name || getFilename(project.worktree) + const projectOptions = createMemo(() => [ + { id: "all", label: language.t("settings.workspaces.filter.all") }, + ...projects().map((project) => ({ id: project.id, label: projectName(project) })), + ]) + const selectedProject = createMemo(() => + store.project === "all" || projects().some((project) => project.id === store.project) ? store.project : "all", + ) + const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject())) + const captureDeleteContext = () => { + const sdk = serverSDK() + return { sdk, sync: serverSync(), server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory } + } + const loadSessions = async (context = captureDeleteContext()) => { + const protocol = await context.sdk.protocol + const fetched = + protocol === "v1" + ? await context.sdk.api.session.list({ limit: 1000, order: "desc" }).then((response) => { + if (response.data.length >= 1000) throw new Error("Incomplete legacy session inventory") + return response.data.map(normalizeSessionInfo) + }) + : await listAllSessions(context.sdk.api.session, { order: "desc" }) + return mergeWorkspaceSessionInventory( + fetched, + Object.values(context.sync.session.data.info).filter((session): session is Session => !!session), + ) + } + const sessionQuery = useQuery(() => ({ + queryKey: [serverSDK().scope, null, "settings-workspace-sessions"] as const, + queryFn: () => loadSessions(), + refetchOnMount: "always", + })) + const workspaceSessions = (workspace: Workspace) => { + if (!sessionQuery.isSuccess) return [] + return sessionsForWorkspace(sessionQuery.data ?? [], workspace.directory) + } + const sessionCount = (workspace: Workspace) => { + if (sessionQuery.isPending) return language.t("session.messages.loading") + if (sessionQuery.isError) return language.t("common.requestFailed") + const count = workspaceSessions(workspace).length + return language.plural("settings.workspaces.sessions", count, { + count, + project: projectName(workspace.project), + }) + } + const lastActive = (workspace: Workspace) => { + const updated = workspaceSessions(workspace)[0]?.time.updated + if (!updated) return undefined + return getRelativeTime(new Date(updated).toISOString(), language.t) + } + const sessionTime = (session: Session) => { + if (!session.time.updated) return undefined + return getRelativeTime(new Date(session.time.updated).toISOString(), language.t) + } + + const inspect = async (workspace: Workspace, context = captureDeleteContext()) => { + const [working, branch, sessions] = await Promise.all([ + context.sdk.api.vcs.status({ location: { directory: workspace.directory } }), + context.sdk.api.vcs.diff({ location: { directory: workspace.directory }, mode: "branch" }), + loadSessions(context), + ]) + const result = inspectWorkspaceDeletion({ + workspace: workspace.directory, + activeDirectory: context.activeDirectory, + sessions, + status: working.data.length > 0 || branch.data.length > 0 ? "dirty" : "clean", + }) + return { result, sessions } + } + const inspectionMessage = (result: WorkspaceDeleteInspection) => { + if (result === "active") return language.t("settings.workspaces.delete.blocked.active") + if (result === "linked") return language.t("settings.workspaces.delete.blocked.linked") + if (result === "dirty") return language.t("workspace.status.dirty") + return language.t("workspace.status.clean") + } + const blocked = (result: WorkspaceDeleteInspection) => { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: inspectionMessage(result), + }) + } + + const remove = async (workspace: Workspace, allowDirty = false, context = captureDeleteContext()) => { + const preflight = await inspect(workspace, context) + if (preflight.result !== "safe" && (!allowDirty || preflight.result !== "dirty")) { + blocked(preflight.result) + return + } + const removed = await context.sdk.client.worktree + .remove({ + directory: workspace.project.worktree, + worktreeRemoveInput: { directory: workspace.directory }, + }) + .then((result) => result.data) + .catch((error) => { + showToast({ + variant: "error", + title: language.t("workspace.delete.failed.title"), + description: error instanceof Error ? error.message : language.t("common.requestFailed"), + }) + return false + }) + if (!removed) return + tabs.store.forEach((tab) => { + if (tab.type !== "draft" || tab.server !== context.server) return + const directoryMatches = containsDirectory(workspace.directory, tab.directory) + const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree) + if (!directoryMatches && !worktreeMatches) return + tabs.updateDraft(tab.draftID, { + directory: directoryMatches ? workspace.project.worktree : tab.directory, + worktree: undefined, + }) + }) + clearWorkspaceTerminals( + workspace.directory, + preflight.sessions.map((session) => session.id), + platform, + context.sdk.scope, + ) + context.sync.set( + "project", + produce((draft) => { + const project = draft.find((item) => item.id === workspace.project.id) + if (!project) return + project.sandboxes = (project.sandboxes ?? []).filter( + (directory) => pathKey(directory) !== pathKey(workspace.directory), + ) + }), + ) + } + + let inspectionID = 0 + const releaseConfirmation = () => { + if (store.transaction === "confirm") setStore("transaction", undefined) + } + const transact = async (task: () => Promise) => { + 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: Session[] }> + 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/language.tsx b/packages/app/src/context/language.tsx index e387391f68b..0ab79bb9ebd 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,20 +196,25 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont initialValue: dicts.get(initial) ?? base, }) - const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( - key: keyof Dictionary, + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: 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] +const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value] createEffect(() => { if (typeof document !== "object") return @@ -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 57e1cd86f3a..532c82a37ed 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,7 +1,89 @@ import { describe, expect, test } from "bun:test" -import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { + adaptServerEvent, + adaptWorktreeCompatibilityEvent, + applyWorkspaceOperationEvent, + applyWorktreeEvent, + coalesceServerEvents, + enqueueServerEvent, + resumeStreamAfterPageShow, +} from "./server-sdk" import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" +import { ServerScope } from "@/utils/server-scope" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" + +describe("applyWorktreeEvent", () => { + test("adapts global readiness with the created worktree directory", () => { + const directory = "/repo/worktree-compatible" + const event = adaptWorktreeCompatibilityEvent({ + directory, + payload: { id: "ready", type: "worktree.ready", properties: { name: "compatible" } } as Event, + }) + if (!event) throw new Error("expected worktree event") + + Worktree.pending(ServerScope.local, directory) + applyWorktreeEvent(ServerScope.local, event, "failed") + expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" }) + expect( + adaptWorktreeCompatibilityEvent({ + directory: "/repo", + payload: { + id: "status", + type: "session.status", + properties: { sessionID: "session", status: { type: "idle" } }, + } as Event, + }), + ).toBeUndefined() + }) + + test("preserves the server failure message", () => { + const directory = "/repo/worktree-failed" + Worktree.pending(ServerScope.local, directory) + + applyWorktreeEvent( + ServerScope.local, + { + directory, + payload: { + id: "failed", + type: "worktree.failed", + properties: { name: "failed", message: "bootstrap failed" }, + } as Event, + }, + "fallback", + ) + expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "failed", message: "bootstrap failed" }) + }) +}) + +test("legacy and current moved events complete matching operations", () => { + const events = [ + { + id: "legacy", + payload: { + id: "moved", + type: "session.next.moved", + properties: { timestamp: Date.now(), sessionID: "legacy", location: { directory: "/workspace" } }, + } as Event, + }, + { + id: "current", + payload: adaptServerEvent({ + id: "moved-current", + created: Date.now(), + type: "session.moved", + data: { sessionID: "current", location: { directory: "/workspace" } }, + } as OpenCodeEvent), + }, + ] + events.forEach((event) => { + WorkspaceOperation.start(ServerScope.local, event.id, "move", "/workspace") + applyWorkspaceOperationEvent(ServerScope.local, { directory: "/workspace", payload: event.payload }) + expect(WorkspaceOperation.get(ServerScope.local, event.id)?.status).toBe("complete") + }) +}) describe("resumeStreamAfterPageShow", () => { test("restarts a stream only after a back-forward cache restore", () => { diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 7dd2a6e59ed..1d192d406ed 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -13,6 +13,8 @@ import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" +import { Worktree } from "@/utils/worktree" +import { WorkspaceOperation } from "@/utils/workspace-operation" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" @@ -56,6 +58,11 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent } +export function adaptWorktreeCompatibilityEvent(event: { directory?: string; payload: Event }) { + if (event.payload.type !== "worktree.ready" && event.payload.type !== "worktree.failed") return + return { directory: event.directory ?? "global", payload: event.payload } +} + const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` if (event.payload.type === "message.part.updated") { @@ -138,6 +145,31 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) { + if (event.payload.type === "worktree.ready") { + Worktree.ready(scope, event.directory) + return true + } + if (event.payload.type !== "worktree.failed") return false + const message = event.payload.properties.message ?? fallback + Worktree.failed(scope, event.directory, message) + return true +} + +export function applyWorkspaceOperationEvent(scope: ServerScope, event: QueuedServerEvent) { + if (event.payload.current?.type === "session.moved") { + WorkspaceOperation.complete( + scope, + event.payload.current.data.sessionID, + event.payload.current.data.location.directory, + ) + return true + } + if (event.payload.type !== "session.next.moved") return false + WorkspaceOperation.complete(scope, event.payload.properties.sessionID, event.payload.properties.location.directory) + return true +} + function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { if ( event?.type === "session.text.delta" || @@ -186,6 +218,7 @@ type ServerSDKBase = { function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() + const language = useLanguage() const abort = new AbortController() const eventFetch = (() => { @@ -238,7 +271,11 @@ 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) => { + applyWorktreeEvent(scope, event, language.t("common.requestFailed")) + applyWorkspaceOperationEvent(scope, event) + emitter.emit(event.directory, event.payload) + }) }) buffer.length = 0 @@ -251,12 +288,57 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } let streamErrorLogged = false + let worktreeStreamErrorLogged = false const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) let attempt: AbortController | undefined + let worktreeAttempt: AbortController | undefined let run: Promise | undefined let started = false let generation = 0 + const consumeWorktreeEvents = async (active: number) => { + // Current worktree lifecycle events are still emitted only on the global compatibility stream. + while (!abort.signal.aborted && started && generation === active) { + const controller = new AbortController() + worktreeAttempt = controller + const onAbort = () => controller.abort() + abort.signal.addEventListener("abort", onAbort) + try { + const events = (await eventSdk.global.event({ signal: controller.signal })).stream + let yielded = Date.now() + for await (const event of events) { + const queued = adaptWorktreeCompatibilityEvent({ + directory: event.directory, + payload: event.payload as Event, + }) + if (queued) { + worktreeStreamErrorLogged = false + if (enqueueServerEvent(queue, queued)) schedule() + } + + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!isStreamClosed(error, controller.signal) && !worktreeStreamErrorLogged) { + worktreeStreamErrorLogged = true + console.error("[global-sdk] worktree event stream failed", { + url: server.http.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + } + } finally { + abort.signal.removeEventListener("abort", onAbort) + if (worktreeAttempt === controller) worktreeAttempt = undefined + } + + if (abort.signal.aborted || !started || generation !== active) return + await wait(RECONNECT_DELAY_MS) + } + } + const start = () => { if (started) return run started = true @@ -264,6 +346,8 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS const previous = run const current = (async () => { if (previous) await previous + const kind = await protocol + if (kind === "v2") void consumeWorktreeEvents(active) // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() @@ -272,7 +356,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } abort.signal.addEventListener("abort", onAbort) try { - const kind = await protocol const events = kind === "v1" ? (await eventSdk.global.event({ signal: attempt.signal })).stream @@ -320,6 +403,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() + worktreeAttempt?.abort() } onMount(() => { @@ -433,6 +517,14 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), directory, }), + createApi(next: string) { + return createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (target) => serverSDK.createClient({ directory: target ?? next, throwOnError: true }), + directory: next, + }) + }, event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 2ebf5f88ef0..af86b84fe8a 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -223,6 +223,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"), directory: "/repo/worktree", path: undefined } + const ctx = setup({ child: current }) + ctx.store.remember(current) + + ctx.store.apply({ + type: "session.next.moved", + properties: { + sessionID: "child", + location: { directory: "/repo" }, + subdirectory: "packages/app", + }, + }) + + expect(ctx.store.get("child")).toMatchObject({ directory: "/repo", path: "packages/app" }) + }) + test("loads session content through the server client", async () => { 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 c5d98682f03..3565f58a35a 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -301,22 +301,24 @@ 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 - ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) - : client.session.get({ sessionID }).then((result) => { + ? sessionApi.get({ sessionID }, { signal: options?.signal }).then(normalizeSessionInfo) + : client.session.get({ sessionID }, { signal: options?.signal }).then((result) => { if (!result.data) throw sessionNotFoundError(sessionID) return result.data }) const resolved = request.then((result) => { + if (options?.signal?.aborted) return result if (generations.get(sessionID) !== active) return result return remember(result) }) + if (options?.signal) return resolved requests.set(sessionID, resolved) const cleanup = () => { if (requests.get(sessionID) === resolved) requests.delete(sessionID) @@ -1017,6 +1019,27 @@ export function createServerSession( evict([sessionID]) return } + case "session.next.moved": { + const props = event.properties as { + timestamp: number + sessionID: string + location: { directory: string; workspaceID?: string } + subdirectory?: string + } + const current = data.info[props.sessionID] + if (!current) { + void resolve(props.sessionID, { force: true }).catch(() => {}) + return + } + remember({ + ...current, + directory: props.location.directory, + path: props.subdirectory, + workspaceID: props.location.workspaceID, + time: { ...current.time, updated: props.timestamp }, + }) + return + } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) @@ -1340,6 +1363,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: [] }]])) + indexLegacyMessage(input.message) setData("message", input.sessionID, (messages = []) => merge(messages, [input.message]).sort(compareMessages)) setData( "part_text_accum_delta", @@ -1373,6 +1397,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 7821a09e207..edf9e52ce9f 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,18 +1,26 @@ import { describe, expect, test } from "bun:test" -import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { Event, OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" import type { McpListInput, McpResourceCatalogInput, SessionApi, SessionInfo, SessionListInput, + OpenCodeEvent, } from "@opencode-ai/client/promise" import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { 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"] @@ -102,6 +110,52 @@ describe("active session query", () => { }) }) +describe("session move normalization", () => { + test("captures and applies current moves from the source placement", () => { + const session = createServerSession({} as OpencodeClient) + session.remember(sessionAt("/source")) + const current = { + id: "event-current-move", + created: 10, + type: "session.moved", + data: { sessionID: "session", location: { directory: "/destination" } }, + } as OpenCodeEvent + const event = adaptServerEvent(current) + + expect(captureSessionMove(event, session.get)).toEqual({ + sessionID: "session", + from: "/source", + refresh: "session.next.moved", + }) + session.applyV2(current) + session.apply(event) + expect(session.get("session")?.directory).toBe("/destination") + }) + + test("captures and applies V1 moves from the source placement", () => { + const session = createServerSession({} as OpencodeClient) + session.remember(sessionAt("/source")) + const event = { + type: "session.next.moved", + properties: { + timestamp: 10, + sessionID: "session", + location: { directory: "/destination" }, + subdirectory: "packages/app", + }, + } as Event + + expect(captureSessionMove(event, session.get)).toEqual({ + sessionID: "session", + from: "/source", + refresh: "session.next.moved", + }) + session.apply(event) + expect(session.get("session")).toMatchObject({ directory: "/destination", path: "packages/app" }) + }) + +}) + describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { const now = 5_000 @@ -174,6 +228,18 @@ function sessionInfo(id: string) { } as SessionInfo } +function sessionAt(directory: string): Session { + return { + id: "session", + slug: "session", + projectID: "project", + directory, + title: "Session", + version: "", + time: { created: 1, updated: 1 }, + } +} + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 13a0b74bc6f..9dd10ab3525 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -12,7 +12,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, @@ -46,6 +46,32 @@ import { ServerConnection, useServer } from "./server" import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { createHomeSessionIndexCache } from "./global-sync/home-session-index" + +export function captureSessionMove(event: ServerEvent, get: (sessionID: string) => { directory: string } | undefined) { + const sessionID = + event.current?.type === "session.moved" + ? event.current.data.sessionID + : event.type === "session.next.moved" + ? event.properties.sessionID + : undefined + if (!sessionID) return + return { sessionID, from: get(sessionID)?.directory, refresh: "session.next.moved" as const } +} + +export function shouldRefreshWorkspaceSessions(event: ServerEvent) { + const type: string = event.type + const current: string | undefined = event.current?.type + return ( + type === "session.created" || + type === "session.updated" || + type === "session.deleted" || + type === "session.next.moved" || + current === "session.moved" || + current === "session.renamed" || + current === "session.archived" || + current === "session.forked" + ) +} import { persisted } from "@/utils/persist" import type { ServerApi } from "@/utils/server" import type { @@ -528,19 +554,55 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }) } + const reindexSession = (sessionID: string, from?: string) => { + const next = session.get(sessionID) + if (!next) return + indexSession(next) + if (!from) return + const source = children.children[directoryKey(from)] + if (!source) return + applyDirectoryEvent({ + event: { + type: "session.moved", + properties: { + sessionID, + projectID: next.projectID, + location: { directory: next.directory, workspaceID: next.workspaceID }, + subpath: next.path, + }, + }, + directory: from, + store: source[0], + setStore: source[1], + push: queue.push, + retainedLimit: sessionMeta.get(directoryKey(from))?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + const moved = captureSessionMove(event, session.get) if (event.current) session.applyV2(event.current) session.apply(event) + if (moved) reindexSession(moved.sessionID, moved.from) + if (shouldRefreshWorkspaceSessions(event)) { + void queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "settings-workspace-sessions", + }) + } if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) } - homeSessions.refresh(event.type) + homeSessions.refresh(moved?.refresh ?? event.type) if (eventType === "integration.connection.updated") void refreshProviders() if (directory === "global") { @@ -572,10 +634,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 }) @@ -688,6 +746,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 1a118b65447..7a10b8e846b 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -3,6 +3,10 @@ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid- import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" import { usePlatform } from "@/context/platform" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" + +export type WorkspaceDefaultDestination = "last-used" | "local" | "new" +export type WorkspaceLastUsed = "local" | "workspace" export interface NotificationSettings { agent: boolean @@ -50,6 +54,10 @@ export interface Settings { permissions: { autoApprove: boolean } + workspaces: { + defaultDestination: WorkspaceDefaultDestination + lastUsed: Record + } notifications: NotificationSettings sounds: SoundSettings } @@ -206,6 +214,10 @@ const defaultSettings: Settings = { permissions: { autoApprove: false, }, + workspaces: { + defaultDestination: "last-used", + lastUsed: {}, + }, notifications: { agent: true, permissions: true, @@ -355,6 +367,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setStore("general", "followup", "steer") }) + createEffect(() => { + if (!ready() || !newLayoutDesigns() || store.general?.showFileTree !== true) return + setStore("general", "showFileTree", false) + }) + return { ready, get current() { @@ -453,7 +470,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, visibility: { - fileTree: visible(showFileTree), + fileTree: createMemo(() => !newLayoutDesigns()), search: visible(showSearch), status: visible(showStatus), customAgents: visible(showCustomAgents), @@ -499,6 +516,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 f0e08a111b5..6894f775023 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -190,6 +190,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..a64d33b16c3 100644 --- a/packages/app/src/i18n/parity.test.ts +++ b/packages/app/src/i18n/parity.test.ts @@ -100,11 +100,14 @@ describe("i18n parity", () => { test("non-English locales have every English key and required 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 missing = Object.keys(source) + .filter((key) => !Object.hasOwn(target, key)) + .sort() 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}`)) @@ -200,7 +203,6 @@ describe("i18n plural parity", () => { } }) }) - 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 +222,19 @@ 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) return + const category = key.slice(split + 1) + if (!["zero", "one", "two", "few", "many", "other"].includes(category)) 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 25d896393ca..85cc47a9a9b 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.directory) if ((await ctx.sdk.protocol) !== "v1") return await archiveHomeSession({ diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 95f8c01843f..cc8cb2c61b7 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -60,6 +60,7 @@ import { useDirectoryPicker } from "@/components/directory-picker" import { ServerConnection, useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" import { pathKey } from "@/utils/path-key" +import { WorkspaceOperation } from "@/utils/workspace-operation" import { displayName, effectiveWorkspaceOrder, @@ -869,6 +870,7 @@ export default function LegacyLayout(props: ParentProps) { } async function archiveSession(session: Session) { + if (WorkspaceOperation.get(serverSDK().scope, session.id)?.status === "pending") return if ((await serverSDK().protocol) !== "v1") return const [store, setStore] = serverSync().child(session.directory) const sessions = store.session ?? [] @@ -1117,7 +1119,7 @@ export default function LegacyLayout(props: ParentProps) { : import("@/components/dialog-settings") void module.then((x) => { if (dialogDead || dialogRun !== run) return - dialog.show(() => ) + dialog.show(() => ) }) } 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 117553a38b2..139437e2caf 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 2e647e0f478..19ba0cb1eea 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -37,6 +37,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" @@ -100,6 +101,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 { createSessionOwnership } from "./session/session-ownership" import { createSessionLineage } from "./session/session-lineage" @@ -532,6 +534,7 @@ export default function Page() { } const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) + const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, info()?.directory ?? sdk().directory)) const isChildSession = createMemo(() => !!info()?.parentID) const canReview = createMemo(() => !!sync().project) const reviewTab = createMemo(() => isDesktop()) @@ -600,6 +603,7 @@ export default function Page() { const [store, setStore] = createStore({ ...sessionViewState(), newSessionWorktree: "main", + sessionDetailsOpen: false, deferRender: false, }) @@ -699,6 +703,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) const reviewDiffs = () => { if (reviewMode() === "git" || reviewMode() === "branch") @@ -1701,6 +1718,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 = params.id @@ -1714,8 +1733,20 @@ export default function Page() { return followup.edit[id] }) + const workspaceMoveEligible = createMemo(() => { + const id = params.id + if (!id) return false + return canMoveSessionToWorkspace({ + queued: followup.items[id]?.length ?? 0, + failed: !!followup.failed[id], + paused: !!followup.paused[id], + editing: !!followup.edit[id], + }) + }) + const followupMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => { + if (workspaceOperationPending(input.sessionID)) return const owner = sessionOwnership.capture() const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id) if (!item) return @@ -1725,6 +1756,7 @@ export default function Page() { const ok = await sendFollowupDraft({ api: sdk().api.session, + scope: serverSDK().scope, sync: sync(), serverSync: serverSync(), draft: item, @@ -1787,6 +1819,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() @@ -1826,6 +1859,7 @@ export default function Page() { const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { + if (workspaceOperationPending(input.sessionID)) return const session = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert @@ -1848,6 +1882,7 @@ export default function Page() { mutationFn: async (id: string) => { const sessionID = params.id if (!sessionID) return + if (workspaceOperationPending(sessionID)) return const session = sdk().api.session const target = sync() @@ -1877,7 +1912,10 @@ export default function Page() { }, })) - const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending) + const reverting = createMemo(() => { + const id = 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 }) => { @@ -1937,6 +1975,7 @@ export default function Page() { if (isChildSession()) return if (composer.blocked()) return if (busy(sessionID)) return + if (workspaceOperationPending(sessionID)) return void sendFollowup(sessionID, item.id) }) @@ -2044,7 +2083,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)} @@ -2107,6 +2146,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 @@ -2234,7 +2276,9 @@ 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 6f074fa2030..31d54f42366 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -783,10 +783,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 25abdc9e734..9847447ecdd 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -28,6 +28,7 @@ import { type UserActions, } from "@opencode-ai/session-ui/message-part" import { DiffChanges } from "@opencode-ai/ui/diff-changes" +import { DiffChanges as DiffChangesV2 } from "@opencode-ai/ui/v2/diff-changes-v2" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -35,6 +36,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 { Dialog } from "@opencode-ai/ui/dialog" import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2" import { InlineInput } from "@opencode-ai/ui/inline-input" @@ -49,6 +52,7 @@ import type { AssistantMessage, Message as MessageType, Part as PartType, + Project, ToolPart, UserMessage, } from "@opencode-ai/sdk/v2" @@ -64,6 +68,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" import { useSessionKey } from "@/pages/session/session-layout" import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -77,6 +82,12 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { createTimelineProjection } from "./projection" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" +import { isWorkspaceDirectory } from "@/utils/workspace" +import { useCommand } from "@/context/command" +import { WorkspaceOperation } from "@/utils/workspace-operation" +import { SessionWorkspaceMenu } from "@/components/session-workspace-menu" +import { getProjectAvatarVariant } from "@/context/layout" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" const emptyMessages: MessageType[] = [] const emptyParts: PartType[] = [] @@ -142,7 +153,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({ @@ -170,6 +181,7 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) { {showAll() ? language.t("ui.sessionTurn.diffs.showLess") : language.t("ui.sessionTurn.diffs.showAll")} + {props.action}
@@ -1128,7 +1374,7 @@ export function MessageTimeline(props: { ) return ( -
+
@@ -1179,7 +1425,7 @@ export function MessageTimeline(props: { {(message) => ( -
+
) } + case "WorkspaceLifecycle": { + const workspaceRow = row as Accessor> + const operation = () => workspaceRow().notice.operation + const pending = () => operation().status === "pending" + const status = () => { + if (operation().status === "failed") return language.t("workspace.move.failed") + if (operation().type === "create") + return language.t(pending() ? "workspace.lifecycle.creating" : "workspace.lifecycle.created") + return language.t(pending() ? "workspace.lifecycle.moving" : "workspace.lifecycle.set") + } + const directory = () => getFilename(operation().directory) + return ( + +
+
+ + +
+ } + > +
+ {status()} + + · + + + {directory()} + + +
+ +
+
+ + ) + } case "TurnDivider": { const turnDividerRow = row as Accessor> return ( -
+
> return ( -
+
> return ( -
+
> return ( -
+
@@ -1251,10 +1541,35 @@ export function MessageTimeline(props: { } case "DiffSummary": { const diffSummaryRow = row as Accessor> + const canMove = () => + settings.general.newLayoutDesigns() && + diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id && + !workspaceSession() && + props.workspaceMoveEligible && + sync().project?.vcs === "git" && + sessionStatus().type === "idle" return ( -
- +
+ + {(project) => ( + setWorkspaceSuggestionDismissed(true)} + /> + )} + + } + />
) @@ -1263,7 +1578,7 @@ export function MessageTimeline(props: { const errorRow = row as Accessor> return ( -
+
{errorRow().text} @@ -1345,7 +1660,7 @@ export function MessageTimeline(props: { } return ( -
+
+ + + + + } + > + + + + } + > + + + + + + + +
@@ -1525,6 +1889,47 @@ export function MessageTimeline(props: { placement="bottom" buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"} /> + + {(project) => ( + + } + variant="ghost-muted" + size="large" + state={summaryOpen() ? "pressed" : undefined} + aria-label={language.t("session.summary.title")} + aria-expanded={summaryOpen()} + /> + + + setWorkspaceSuggestionDismissed(true)} + onReview={() => { + setSummary(false) + command.trigger("review.toggle") + }} + /> + + + + )} + - exportSession(id)}> + exportSession(id)} + > {language.t("common.export")} - void archiveSession(id)}> + void archiveSession(id)} + > {language.t("common.archive")} dialog.show(() => )} > {language.t("common.delete")} @@ -1664,14 +2076,20 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... - exportSession(id)}> + exportSession(id)}> {language.t("common.export")}... - void archiveSession(id)}> + void archiveSession(id)} + > {language.t("common.archive")} - dialog.show(() => )}> + dialog.show(() => )} + > {language.t("common.delete")}... diff --git a/packages/app/src/pages/session/timeline/projection.test.ts b/packages/app/src/pages/session/timeline/projection.test.ts index 68da2c2fe4d..4d24ae2449c 100644 --- a/packages/app/src/pages/session/timeline/projection.test.ts +++ b/packages/app/src/pages/session/timeline/projection.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PartGroup } from "@opencode-ai/session-ui/message-part" -import { reuseTimelineRows } from "./row-reconciliation" +import { insertAfterUserMessage, reuseTimelineRows } from "./row-reconciliation" import { TimelineRow } from "./timeline-row" const context = (key: string, partIDs: string[], userMessageID = "user-1") => @@ -94,3 +94,20 @@ describe("reuseTimelineRows", () => { reused.forEach(([resultIndex, previousIndex]) => expect(result[resultIndex]).toBe(previous[previousIndex])) }) }) + +test("inserts lifecycle extensions immediately after the user message", () => { + const rows: TimelineRow.TimelineRow[] = [user(), new TimelineRow.DiffSummary({ userMessageID: "user-1", diffs: [] })] + const lifecycle = new TimelineRow.WorkspaceLifecycle({ + userMessageID: "user-1", + notice: { + type: "operation", + operation: { type: "move", status: "complete", directory: "/workspace", messageID: "user-1" }, + }, + }) + + expect(insertAfterUserMessage(rows, [lifecycle]).map((row) => row._tag)).toEqual([ + "UserMessage", + "WorkspaceLifecycle", + "DiffSummary", + ]) +}) diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index e30c936d73c..375e7f04cd2 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -14,6 +14,8 @@ export function createTimelineProjection(input: { status: Accessor showReasoningSummaries: Accessor inlineComments: Accessor + extensionRevision?: Accessor + afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[] }) { const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) const assistantMessagesByParent = createMemo(() => { @@ -29,8 +31,9 @@ export function createTimelineProjection(input: { }) return result }) - const projection = createMemo(() => - Timeline.constructSessionMessageRows( + const projection = createMemo(() => { + const extension = input.extensionRevision?.() + return Timeline.constructSessionMessageRows( input.sessionMessages(), (messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined, input.parts, @@ -38,8 +41,9 @@ export function createTimelineProjection(input: { input.status().type, input.inlineComments(), input.userMessages(), - ), - ) + input.extensionRevision && extension === undefined ? undefined : input.afterUser, + ) + }) const activeMessageID = createMemo(() => projection().activeMessageID) const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => reuseTimelineRows(previous, projection().rows), diff --git a/packages/app/src/pages/session/timeline/row-reconciliation.ts b/packages/app/src/pages/session/timeline/row-reconciliation.ts index ffd615ae218..d601dd730e6 100644 --- a/packages/app/src/pages/session/timeline/row-reconciliation.ts +++ b/packages/app/src/pages/session/timeline/row-reconciliation.ts @@ -3,6 +3,12 @@ import { TimelineRow } from "./timeline-row" type ContextRow = Extract type PriorContext = { index: number; row: ContextRow } +export function insertAfterUserMessage(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) { + const index = rows.findIndex((row) => row._tag === "UserMessage") + rows.splice(index + 1, 0, ...extensions) + return rows +} + export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefined, rows: TimelineRow.TimelineRow[]) { if (!previous?.length) return rows const byKey = new Map(previous.map((row) => [TimelineRow.key(row), row] as const)) diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index 158897ba5ea..81c784cd835 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -13,6 +13,14 @@ mock.module("@opencode-ai/session-ui/message-part", () => ({ })) const { Timeline, TimelineRow } = await import("./rows") +const lifecycle = (userMessageID: string) => + new TimelineRow.WorkspaceLifecycle({ + userMessageID, + notice: { + type: "operation", + operation: { type: "create", status: "complete", directory: "/workspace", messageID: userMessageID }, + }, + }) describe("current session timeline rows", () => { test("derives turns and tagged rows from chronological current messages", () => { @@ -47,6 +55,7 @@ describe("current session timeline rows", () => { "busy", true, normalized.messages.filter((message) => message.role === "user"), + (message) => (message.id === "msg_3" ? [lifecycle(message.id)] : []), ) expect(result.activeMessageID).toBe("msg_3") @@ -55,6 +64,7 @@ describe("current session timeline rows", () => { "assistant-part:msg_1:msg_2:text:0", "turn-gap:msg_3", "user-message:msg_3", + "workspace-lifecycle:msg_3:operation", "assistant-part:msg_3:msg_4:reasoning:0", ]) }) @@ -83,11 +93,13 @@ describe("current session timeline rows", () => { "idle", true, normalized.messages.filter((message) => message.role === "user"), + (message) => [lifecycle(message.id)], ) expect(result.activeMessageID).toBe("msg_shell") expect(result.rows.map(TimelineRow.key)).toEqual([ "user-message:msg_shell", + "workspace-lifecycle:msg_shell:operation", "assistant-part:msg_shell:msg_shell:tool", ]) }) @@ -157,6 +169,7 @@ describe("current session timeline rows", () => { "busy", true, [...normalized.messages.filter((message) => message.role === "user"), optimistic], + (message) => (message.id === optimistic.id ? [lifecycle(message.id)] : []), ) expect(result.activeMessageID).toBe(optimistic.id) @@ -164,6 +177,7 @@ describe("current session timeline rows", () => { "user-message:msg_z", "turn-gap:msg_a", "user-message:msg_a", + "workspace-lifecycle:msg_a:operation", "thinking:msg_a", ]) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 1f9472a0a58..3e9e4199818 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -5,6 +5,7 @@ import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/ import { TimelineRow, type SummaryDiff } from "./timeline-row" import { uniqueSummaryDiffs } from "./summary-diffs" import { compareMessages } from "@/utils/session-message" +import { insertAfterUserMessage } from "./row-reconciliation" export { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -28,6 +29,10 @@ export type TimelineRowMap = { } Thinking: { userMessageID: string; reasoningHeading?: string } Retry: { userMessageID: string } + WorkspaceLifecycle: { + userMessageID: string + notice: TimelineRow.WorkspaceLifecycle["notice"] + } DiffSummary: { userMessageID: string; diffs: SummaryDiff[] } Error: { userMessageID: string; text: string } } @@ -41,6 +46,7 @@ export namespace Timeline { status: SessionStatus["type"], inlineComments: boolean, projectedUserMessages: UserMessage[], + afterUser?: (message: UserMessage) => TimelineRow.TimelineRow[], ) { const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] const turnByUserID = new Map() @@ -83,8 +89,8 @@ export namespace Timeline { const activeMessageID = turns.at(-1)?.user.id return { activeMessageID, - rows: turns.flatMap((turn, index) => - constructMessageRows( + rows: turns.flatMap((turn, index) => { + const rows = constructMessageRows( turn.user, getMessageParts, turn.assistants, @@ -93,8 +99,10 @@ export namespace Timeline { status, turn.user.id === activeMessageID, inlineComments, - ), - ), + ) + if (!afterUser) return rows + return insertAfterUserMessage(rows, afterUser(turn.user)) + }), } } diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 3905254b29b..9869ba4ae20 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -1,6 +1,7 @@ import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" import type { PartGroup } from "@opencode-ai/session-ui/message-part" import { Data, Equal } from "effect" +import type { WorkspaceOperationState } from "@/utils/workspace-operation" export type SummaryDiff = SnapshotFileDiff & { file: string } @@ -39,6 +40,10 @@ export namespace TimelineRow { export class Retry extends Data.TaggedClass("Retry")<{ userMessageID: string }> {} + export class WorkspaceLifecycle extends Data.TaggedClass("WorkspaceLifecycle")<{ + userMessageID: string + notice: { type: "operation"; operation: WorkspaceOperationState } + }> {} export type TimelineRow = | TurnGap @@ -50,6 +55,7 @@ export namespace TimelineRow { | DiffSummary | Error | Retry + | WorkspaceLifecycle export const key = (row: TimelineRow) => { switch (row._tag) { @@ -71,6 +77,8 @@ export namespace TimelineRow { return `error:${row.userMessageID}` case "Retry": return `retry:${row.userMessageID}` + case "WorkspaceLifecycle": + return `workspace-lifecycle:${row.userMessageID}:${row.notice.type}` } } diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index c4c8c44a99f..05b432b4c94 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -20,6 +20,7 @@ import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" +import { WorkspaceOperation } from "@/utils/workspace-operation" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -74,6 +75,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { if (!id) return return sync().session.get(id) } + const workspaceOperationPending = (sessionID: string) => + WorkspaceOperation.get(sdk().scope, sessionID)?.status === "pending" const hasReview = () => !!params.id const normalizeTab = (tab: string) => { if (!tab.startsWith("file://")) return tab @@ -332,6 +335,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const undo = async () => { const sessionID = params.id if (!sessionID) return + if (workspaceOperationPending(sessionID)) return const owner = sessionOwnership.capture() const session = sdk().api.session const directory = sdk().directory @@ -362,6 +366,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const redo = async () => { const sessionID = params.id if (!sessionID) return + if (workspaceOperationPending(sessionID)) return const owner = sessionOwnership.capture() const session = sdk().api.session const messages = userMessages() @@ -396,6 +401,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const compact = async () => { const sessionID = params.id if (!sessionID) return + if (workspaceOperationPending(sessionID)) return const model = local.model.current() if (!model) { @@ -413,6 +419,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const fork = () => { + const sessionID = params.id + if (!sessionID) return + if (workspaceOperationPending(sessionID)) return void openDialog( () => import("@/components/dialog-fork"), (x) => dialog.show(() => ), @@ -462,7 +471,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.undo"), description: language.t("command.session.undo.description"), slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id), onSelect: undo, }), sessionCommand({ @@ -470,7 +479,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.redo"), description: language.t("command.session.redo.description"), slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, + disabled: !params.id || !info()?.revert?.messageID || workspaceOperationPending(params.id), onSelect: redo, }), sessionCommand({ @@ -478,7 +487,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.compact"), description: language.t("command.session.compact.description"), slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id), onSelect: compact, }), sessionCommand({ @@ -486,7 +495,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { title: language.t("command.session.fork"), description: language.t("command.session.fork.description"), slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, + disabled: !params.id || visibleUserMessages().length === 0 || workspaceOperationPending(params.id), onSelect: fork, }), sessionCommand({ diff --git a/packages/app/src/utils/workspace-operation.test.ts b/packages/app/src/utils/workspace-operation.test.ts new file mode 100644 index 00000000000..1eb29e7ed25 --- /dev/null +++ b/packages/app/src/utils/workspace-operation.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test" +import { ServerScope } from "./server-scope" +import { canMoveSessionToWorkspace, WorkspaceOperation } from "./workspace-operation" + +test("workspace moves require settled followup state", () => { + expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: false })).toBe(true) + expect(canMoveSessionToWorkspace({ queued: 1, failed: false, paused: false, editing: false })).toBe(false) + expect(canMoveSessionToWorkspace({ queued: 0, failed: true, paused: false, editing: false })).toBe(false) + expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: true, editing: false })).toBe(false) + expect(canMoveSessionToWorkspace({ queued: 0, failed: false, paused: false, editing: true })).toBe(false) +}) + +describe("WorkspaceOperation", () => { + test("settles only the matching pending operation", () => { + WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace") + expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending") + WorkspaceOperation.complete(ServerScope.local, "session", "/other") + expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending") + WorkspaceOperation.complete(ServerScope.local, "session", "/workspace") + WorkspaceOperation.fail(ServerScope.local, "session") + expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete") + }) +}) diff --git a/packages/app/src/utils/workspace-operation.ts b/packages/app/src/utils/workspace-operation.ts new file mode 100644 index 00000000000..acae45ac637 --- /dev/null +++ b/packages/app/src/utils/workspace-operation.ts @@ -0,0 +1,52 @@ +import { createSignal } from "solid-js" +import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { pathKey } from "@/utils/path-key" + +export type WorkspaceOperationType = "create" | "move" +export type WorkspaceOperationState = { + type: WorkspaceOperationType + status: "pending" | "complete" | "failed" + directory: string + messageID?: string +} + +export function canMoveSessionToWorkspace(input: { + queued: number + failed: boolean + paused: boolean + editing: boolean +}) { + return input.queued === 0 && !input.failed && !input.paused && !input.editing +} + +const state = new Map() +const [version, setVersion] = createSignal(0) +const key = (scope: ServerScope, sessionID: string) => ScopedKey.from(scope, sessionID) +const write = (scope: ServerScope, sessionID: string, value: WorkspaceOperationState) => { + if (!state.has(key(scope, sessionID)) && state.size >= 100) { + const terminal = [...state].find(([, item]) => item.status !== "pending")?.[0] ?? state.keys().next().value + if (terminal) state.delete(terminal) + } + state.set(key(scope, sessionID), value) + setVersion((current) => current + 1) +} +export const WorkspaceOperation = { + get(scope: ServerScope, sessionID: string) { + version() + return state.get(key(scope, sessionID)) + }, + start(scope: ServerScope, sessionID: string, type: WorkspaceOperationType, directory: string, messageID?: string) { + write(scope, sessionID, { type, directory, messageID, status: "pending" }) + }, + complete(scope: ServerScope, sessionID: string, directory?: string) { + const current = state.get(key(scope, sessionID)) + if (!current) return + if (directory && pathKey(directory) !== pathKey(current.directory)) return + write(scope, sessionID, { ...current, status: "complete" }) + }, + fail(scope: ServerScope, sessionID: string) { + const current = state.get(key(scope, sessionID)) + if (!current || current.status === "complete") return + write(scope, sessionID, { ...current, status: "failed" }) + }, +} diff --git a/packages/app/src/utils/workspace-request.ts b/packages/app/src/utils/workspace-request.ts new file mode 100644 index 00000000000..d899cf3d80b --- /dev/null +++ b/packages/app/src/utils/workspace-request.ts @@ -0,0 +1,25 @@ +export const WORKSPACE_PREPARATION_TIMEOUT_MS = 5 * 60 * 1000 +export const WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS = 30_000 + +export async function workspaceRequestWithTimeout( + request: (signal: AbortSignal) => Promise, + message: string, + timeoutMs: number, +) { + const controller = new AbortController() + const timer = { id: undefined as ReturnType | undefined } + const timeout = new Promise((_, reject) => { + timer.id = setTimeout(() => { + controller.abort() + reject(new Error(message)) + }, timeoutMs) + }) + return Promise.race([request(controller.signal), timeout]) + .catch((error) => { + if (controller.signal.aborted) throw new Error(message) + throw error + }) + .finally(() => { + if (timer.id !== undefined) clearTimeout(timer.id) + }) +} diff --git a/packages/app/src/utils/workspace.test.ts b/packages/app/src/utils/workspace.test.ts new file mode 100644 index 00000000000..9b61c22a31f --- /dev/null +++ b/packages/app/src/utils/workspace.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test" +import type { Session } from "@opencode-ai/sdk/v2/client" +import { + filterWorkspaceInventory, + inspectWorkspaceDeletion, + isWorkspaceDirectory, + isWorkspaceSelection, + mergeWorkspaceSessionInventory, + sessionsForWorkspace, + workspaceInventory, +} from "./workspace" + +describe("isWorkspaceDirectory", () => { + const project = { + worktree: "C:\\repo\\", + sandboxes: ["C:\\repo-workspaces\\feature\\", "C:\\repo-workspaces\\other"], + } + + test("distinguishes managed workspaces from the local repository", () => { + expect(isWorkspaceDirectory(project, "C:\\repo")).toBe(false) + expect(isWorkspaceDirectory(project, "C:\\repo-workspaces\\feature")).toBe(true) + expect(isWorkspaceDirectory(project, "c:\\repo-workspaces\\feature\\packages\\app")).toBe(true) + expect( + isWorkspaceDirectory({ worktree: "/repo", sandboxes: ["/repo/.worktrees/feature"] }, "/repo/.worktrees/feature"), + ).toBe(true) + expect(isWorkspaceDirectory(project, "C:\\other")).toBe(false) + expect(isWorkspaceDirectory(undefined, "C:\\repo-workspaces\\feature")).toBe(false) + }) +}) + +describe("isWorkspaceSelection", () => { + const project = { worktree: "/repo", sandboxes: ["/workspaces/feature"] } + + test("accepts local, new, and managed workspace selections", () => { + expect(isWorkspaceSelection(project, "main")).toBe(true) + expect(isWorkspaceSelection(project, "create")).toBe(true) + expect(isWorkspaceSelection(project, "/repo/")).toBe(true) + expect(isWorkspaceSelection(project, "/workspaces/feature/")).toBe(true) + expect(isWorkspaceSelection({ worktree: "C:\\repo" }, "c:\\repo\\")).toBe(true) + expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false) + }) +}) + +test("groups and filters workspace inventory by project", () => { + const inventory = workspaceInventory([ + { id: "a", worktree: "/a", sandboxes: ["/a", "/a/one", "/a/two"] }, + { id: "b", worktree: "/b", sandboxes: ["/b/one"] }, + ]) + + expect(inventory.map((item) => [item.project.id, item.directory])).toEqual([ + ["a", "/a/one"], + ["a", "/a/two"], + ["b", "/b/one"], + ]) + expect(filterWorkspaceInventory(inventory, "a").map((item) => item.directory)).toEqual(["/a/one", "/a/two"]) + expect(filterWorkspaceInventory(inventory, "all")).toEqual(inventory) +}) + +test("blocks unsafe workspace deletion", () => { + const session = (directory: string) => ({ directory }) as Session + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + activeDirectory: "/workspace/app", + sessions: [], + status: "dirty", + }), + ).toBe("active") + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + sessions: [session("/workspace/packages/app")], + status: "dirty", + }), + ).toBe("linked") + expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "dirty" })).toBe("dirty") + expect(inspectWorkspaceDeletion({ workspace: "/workspace", sessions: [], status: "clean" })).toBe("safe") + expect( + inspectWorkspaceDeletion({ + workspace: "/workspace", + sessions: [{ directory: "/workspace", time: { created: 1, updated: 1, archived: 2 } } as Session], + status: "clean", + }), + ).toBe("safe") +}) + +test("groups nested non-archived workspace sessions by latest activity", () => { + const session = (id: string, directory: string, updated: number, archived?: number) => + ({ id, directory, time: { created: 1, updated, archived } }) as Session + const sessions = sessionsForWorkspace( + [ + session("old", "/workspace", 2), + session("nested", "/workspace/packages/app", 3), + session("archived", "/workspace", 4, 5), + session("other", "/other", 6), + ], + "/workspace", + ) + expect(sessions.map((item) => item.id)).toEqual(["nested", "old"]) +}) + +test("merges workspace placement by freshness with authoritative server ties", () => { + const session = (directory: string, updated: number) => + ({ id: "session", directory, time: { created: 1, updated } }) as Session + + expect(mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 2)])[0]?.directory).toBe( + "/destination", + ) + expect(mergeWorkspaceSessionInventory([session("/destination", 3)], [session("/source", 3)])[0]?.directory).toBe( + "/destination", + ) + expect(mergeWorkspaceSessionInventory([session("/destination", 2)], [session("/source", 3)])[0]?.directory).toBe( + "/source", + ) +}) diff --git a/packages/app/src/utils/workspace.ts b/packages/app/src/utils/workspace.ts new file mode 100644 index 00000000000..f221361fb37 --- /dev/null +++ b/packages/app/src/utils/workspace.ts @@ -0,0 +1,99 @@ +import { pathKey } from "@/utils/path-key" +import type { WorkspaceDefaultDestination, WorkspaceLastUsed } from "@/context/settings" +import type { Session } from "@opencode-ai/sdk/v2/client" + +type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] } + +export function workspaceDirectories(project: WorkspaceProject) { + return (project.sandboxes ?? []).filter( + (directory) => !containsDirectory(project.worktree, directory) || !containsDirectory(directory, project.worktree), + ) +} + +export function workspaceInventory(projects: readonly T[]) { + return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project }))) +} + +export function filterWorkspaceInventory( + workspaces: readonly T[], + project: string, +) { + if (project === "all") return [...workspaces] + return workspaces.filter((workspace) => workspace.project.id === project) +} + +export function sessionsForWorkspace(sessions: readonly Session[], workspace: string) { + return sessions + .filter((session) => session.time.archived === undefined) + .filter((session) => containsDirectory(workspace, session.directory)) + .toSorted((a, b) => b.time.updated - a.time.updated) +} + +export function mergeWorkspaceSessionInventory(server: readonly Session[], cached: readonly Session[]) { + const sessions = new Map(server.map((session) => [session.id, session])) + cached.forEach((session) => { + const current = sessions.get(session.id) + if (!current || session.time.updated > current.time.updated) sessions.set(session.id, session) + }) + return [...sessions.values()] +} + +export function removeWorkspacesSequentially(workspaces: readonly T[], remove: (workspace: T) => Promise) { + return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve()) +} + +export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty" + +export function inspectWorkspaceDeletion(input: { + workspace: string + activeDirectory?: string + sessions: readonly Session[] + status: "clean" | "dirty" +}): WorkspaceDeleteInspection { + if (input.activeDirectory && containsDirectory(input.workspace, input.activeDirectory)) return "active" + if ( + input.sessions.some( + (session) => session.time?.archived === undefined && containsDirectory(input.workspace, session.directory), + ) + ) + return "linked" + if (input.status === "dirty") return "dirty" + return "safe" +} + +export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) { + if (!project || (containsDirectory(project.worktree, directory) && containsDirectory(directory, project.worktree))) + return false + return workspaceDirectories(project).some((workspace) => containsDirectory(workspace, directory)) +} + +export function isProjectDirectory(project: WorkspaceProject | undefined, directory: string) { + if (!project) return false + return [project.worktree, ...(project.sandboxes ?? [])].some((root) => containsDirectory(root, directory)) +} + +export function containsDirectory(parent: string, child: string) { + const normalize = (value: string) => { + const key = pathKey(value) + return /^[a-z]:\//i.test(key) || key.startsWith("//") ? key.toLowerCase() : key + } + const root = normalize(parent) + const target = normalize(child) + return target === root || target.startsWith(root.endsWith("/") ? root : `${root}/`) +} + +export function isWorkspaceSelection(project: WorkspaceProject | undefined, selection: string) { + if (selection === "main" || selection === "create") return true + if (!project) return false + if (containsDirectory(project.worktree, selection) && containsDirectory(selection, project.worktree)) return true + return isWorkspaceDirectory(project, selection) +} + +export function workspaceDefaultSelection( + setting: WorkspaceDefaultDestination, + lastUsed: WorkspaceLastUsed | undefined, +) { + if (setting === "local") return "main" + if (setting === "new") return "create" + return lastUsed === "workspace" ? "create" : "main" +} diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx index 1ee3c674774..db46bcedb36 100644 --- a/packages/enterprise/src/app.tsx +++ b/packages/enterprise/src/app.tsx @@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { I18nProvider } from "@opencode-ai/ui/context" -import { pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" +import { + pluralCategory, + pluralKey, + type UiI18nParams, + type UiI18nPluralKey, + type UiTranslate, + type UiPluralCategory, +} from "@opencode-ai/ui/context/i18n" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js" @@ -58,20 +65,30 @@ function detectLocale() { function UiI18nBridge(props: ParentProps) { const locale = createMemo(() => detectLocale()) const zh = uiZh as Partial> - const t = (key: keyof typeof uiEn, params?: UiI18nParams) => { + const translate = (key: keyof typeof uiEn, params?: UiI18nParams) => { const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key] const text = value ?? String(key) return resolveTemplate(text, params) } + const t = translate as UiTranslate + const pluralForm = (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => { + const candidate = pluralKey(key, category) + const fallback = pluralKey(key, "other") + const value = + locale() === "zh" + ? (zh[candidate] ?? zh[fallback] ?? uiEn[candidate] ?? uiEn[fallback]) + : (uiEn[candidate] ?? uiEn[fallback]) + return resolveTemplate(value ?? fallback, params) + } const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => - t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count }) + pluralForm(key, pluralCategory(locale(), count), { ...params, count }) createEffect(() => { if (typeof document !== "object") return document.documentElement.lang = locale() }) - return {props.children} + return {props.children} } export default function App() { diff --git a/packages/session-ui/AGENTS.md b/packages/session-ui/AGENTS.md index 678ae1bf90d..e6af6bed781 100644 --- a/packages/session-ui/AGENTS.md +++ b/packages/session-ui/AGENTS.md @@ -1,6 +1,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, 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 through `i18n.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `i18n.t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms. - 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. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index ea73be7bff5..807504505ab 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -1382,6 +1382,11 @@ body[data-new-layout] [data-component="user-message"] { background: var(--v2-background-bg-layer-02); } +body[data-new-layout] [data-workspace-session] [data-component="user-message"] [data-slot="user-message-text"] { + background: var(--v2-background-bg-accent); + color: var(--v2-text-text-contrast); +} + body:not([data-new-layout]) { [data-component="user-message"] { color: var(--text-strong); diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index d2a00b9d44c..3b6297c89d9 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -545,7 +545,7 @@ export function getToolInfo( icon: "code-lines", title: i18n.t("ui.tool.patch"), subtitle: input.files?.length - ? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + ? `${input.files.length} ${i18n.plural("ui.common.file", input.files.length)}` : undefined, } case "todowrite": @@ -2344,7 +2344,7 @@ ToolRegistry.register({ const subtitle = createMemo(() => { const count = files().length if (count === 0) return "" - return `${count} ${i18n.t(count > 1 ? "ui.common.file.other" : "ui.common.file.one")}` + return `${count} ${i18n.plural("ui.common.file", count)}` }) return ( @@ -2585,7 +2585,7 @@ ToolRegistry.register({ const count = questions().length if (count === 0) return "" if (completed()) return i18n.t("ui.question.subtitle.answered", { count }) - return `${count} ${i18n.t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}` + return `${count} ${i18n.plural("ui.common.question", count)}` }) return ( diff --git a/packages/session-ui/src/components/tool-count-label.tsx b/packages/session-ui/src/components/tool-count-label.tsx index 1069bbd0d5b..c1281e17a30 100644 --- a/packages/session-ui/src/components/tool-count-label.tsx +++ b/packages/session-ui/src/components/tool-count-label.tsx @@ -27,9 +27,11 @@ function common(one: string, other: string) { export function AnimatedCountLabel(props: { count: number; plural: UiI18nPluralKey; class?: string }) { const i18n = useI18n() const category = createMemo(() => pluralCategory(i18n.locale(), Math.round(props.count))) - const one = createMemo(() => split(i18n.t(pluralKey(props.plural, "one")))) - const other = createMemo(() => split(i18n.t(pluralKey(props.plural, "other")))) - const active = createMemo(() => split(i18n.t(pluralKey(props.plural, category())))) + const form = (category: ReturnType) => + i18n.pluralForm?.(props.plural, category) ?? (i18n.t as (key: string) => string)(pluralKey(props.plural, category)) + const one = createMemo(() => split(form("one"))) + const other = createMemo(() => split(form("other"))) + const active = createMemo(() => split(form(category()))) const suffix = createMemo(() => common(one().after, other().after)) const splitSuffix = createMemo( () => diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 7ab84b7b67e..c5358992c70 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -36,6 +36,7 @@ export type PromptInputV2Mode = "normal" | "shell" export type PromptInputV2Props = { controller: PromptInputV2Interaction + accentSubmit?: boolean disabled?: boolean readOnly?: boolean borderUnderlay?: boolean @@ -258,6 +259,7 @@ export function PromptInputV2(props: PromptInputV2Props) { mode={state.mode} stopping={view.submit.stopping()} disabled={!props.controller.canSubmit()} + accent={props.accentSubmit} sendLabel={i18n.t("ui.promptInput.send")} stopLabel={i18n.t("ui.promptInput.stop")} onSubmit={props.controller.submit} @@ -668,6 +670,7 @@ export function PromptInputV2SubmitButton(props: { mode: PromptInputV2Mode stopping: boolean disabled: boolean + accent?: boolean sendLabel: string stopLabel: string onSubmit: () => void @@ -686,10 +689,16 @@ export function PromptInputV2SubmitButton(props: { tabIndex={props.mode === "normal" ? undefined : -1} icon={props.stopping ? "stop" : props.mode === "shell" ? "arrow-undo-down" : "arrow-up"} variant="primary" - class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50" + class="size-7 rounded-md p-[6px] shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50" + classList={{ + "text-v2-text-text-contrast": !!props.accent && !props.stopping && !props.disabled, + "text-v2-icon-icon-muted": !props.accent || props.stopping || props.disabled, + }} style={{ "background-image": - "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)", + props.accent && !props.stopping && !props.disabled + ? "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-accent) 0%,var(--v2-background-bg-accent) 100%)" + : "linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)", }} aria-label={props.stopping ? props.stopLabel : props.sendLabel} onClick={(event) => { diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 73b12de4d7c..a31b6bfe883 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -1,6 +1,8 @@ ## Localization - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for component defaults, visible copy, placeholders, accessible labels, tooltips, 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 through `plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `t(...)`; `pluralForm(...)` is reserved for components that animate individual grammatical forms. - 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. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index a16fad0c32a..babca7c864e 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -10,6 +10,7 @@ const icons = { prompt: ``, brain: ``, fork: ``, + "workspace-isolated": ``, "bullet-list": ``, "check-small": ``, "chevron-down": ``, diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx index be7a4d4f1e9..afb6e97262e 100644 --- a/packages/ui/src/context/i18n.tsx +++ b/packages/ui/src/context/i18n.tsx @@ -1,26 +1,24 @@ import { createContext, useContext, type Accessor, type ParentProps } from "solid-js" import { I18nProvider } from "@kobalte/core/i18n" import { dict as en } from "../i18n/en" +import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en" -export type UiI18nKey = keyof typeof en - -export const UI_PLURAL_KEYS = [ - "ui.sessionTurn.diffs.changed", - "ui.messagePart.context.read", - "ui.messagePart.context.search", - "ui.messagePart.context.list", -] as const -export type UiI18nPluralKey = (typeof UI_PLURAL_KEYS)[number] -export type UiPluralCategory = "zero" | "one" | "two" | "few" | "many" | "other" -export type UiI18nPluralLookupKey = `${UiI18nPluralKey}.${UiPluralCategory}` +export type UiI18nKey = Key +export type UiI18nPluralKey = PluralKey +export type UiPluralCategory = PluralCategory +export type UiI18nPluralLookupKey = PluralLookupKey +export type UiI18nLocaleKey = LocaleKey +type UiTranslationKey = Key extends UiI18nPluralLookupKey ? never : Key export type UiI18nParams = Record +export type UiTranslate = (key: UiTranslationKey, params?: UiI18nParams) => string export type UiI18n = { locale: Accessor layoutLocale?: Accessor - t: (key: UiI18nKey, params?: UiI18nParams) => string + t: UiTranslate plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string + pluralForm?: (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => string } const rules = new Map() @@ -50,11 +48,16 @@ function resolveTemplate(text: string, params?: UiI18nParams) { const fallback: UiI18n = { locale: () => "en", t: (key, params) => { - const value = en[key] ?? String(key) + const value = en[key as UiI18nKey] ?? String(key) return resolveTemplate(value, params) }, plural: (key, count, params) => - fallback.t(pluralKey(key, pluralCategory(fallback.locale(), count)), { ...params, count }), + fallback.pluralForm!(key, pluralCategory(fallback.locale(), count), { ...params, count }), + pluralForm: (key, category, params) => { + const values = en as Partial> + const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other` + return resolveTemplate(value, params) + }, } const Context = createContext(fallback) diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index aa0ec9c5735..ab0e6fcce1c 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -1,4 +1,4 @@ -export const dict: Record = { +const source = { "ui.sessionReview.title": "Session changes", "ui.sessionReview.title.git": "Git changes", "ui.sessionReview.title.branch": "Branch changes", @@ -217,4 +217,13 @@ export const dict: Record = { "ui.question.multiHint": "Select all answers that apply", "ui.question.singleHint": "Select one answer", "ui.question.custom.placeholder": "Type your answer...", -} +} satisfies Record + +export type Key = keyof typeof source +export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other" +export type PluralKey = { + [Entry in Key]: Entry extends `${infer Base}.other` ? (`${Base}.one` extends Key ? Base : never) : never +}[Key] +export type PluralLookupKey = `${PluralKey}.${PluralCategory}` +export type LocaleKey = Key | PluralLookupKey +export const dict: typeof source & Record = source diff --git a/packages/ui/src/v2/components/dialog-v2.css b/packages/ui/src/v2/components/dialog-v2.css index 132d7fae27a..4a3ed38c375 100644 --- a/packages/ui/src/v2/components/dialog-v2.css +++ b/packages/ui/src/v2/components/dialog-v2.css @@ -93,6 +93,9 @@ [data-slot="dialog-description"] { flex: none; flex-grow: 0; + min-width: 0; + max-width: 100%; + overflow-wrap: anywhere; user-select: none; font-weight: 440; font-size: 13px; diff --git a/packages/ui/src/v2/components/icon.tsx b/packages/ui/src/v2/components/icon.tsx index b22d3140fe5..2b933bcabd1 100644 --- a/packages/ui/src/v2/components/icon.tsx +++ b/packages/ui/src/v2/components/icon.tsx @@ -1,5 +1,6 @@ import { onMount, type ComponentProps, splitProps } from "solid-js" +// Consumers center the SVG viewport, so each icon must center its artwork within its viewBox. const icons = { edit: { viewBox: "0 0 16 16", @@ -17,6 +18,10 @@ const icons = { viewBox: "0 0 16 16", body: ``, }, + "branch-out": { + viewBox: "0 0 16 16", + body: ``, + }, "grid-plus": { viewBox: "0 0 16 16", body: ``, @@ -121,6 +126,14 @@ const icons = { viewBox: "0 0 20 20", body: ``, }, + "window-analytics": { + viewBox: "0 0 16 16", + body: ``, + }, + trash: { + viewBox: "0 0 20 20", + body: ``, + }, "outline-sliders": { viewBox: "0 0 16 16", body: ``, diff --git a/packages/ui/src/v2/components/tooltip-v2.tsx b/packages/ui/src/v2/components/tooltip-v2.tsx index a2c8e6d1d1e..2aa2f3e7560 100644 --- a/packages/ui/src/v2/components/tooltip-v2.tsx +++ b/packages/ui/src/v2/components/tooltip-v2.tsx @@ -35,6 +35,8 @@ export function TooltipV2(props: TooltipV2Props) { "value", ]) + const controlled = () => local.forceOpen !== undefined + const inside = () => { const active = document.activeElement if (!ref || !active) return false @@ -119,9 +121,9 @@ export function TooltipV2(props: TooltipV2Props) { {...others} closeDelay={0} ignoreSafeArea={local.ignoreSafeArea ?? true} - open={local.forceOpen || state.open} + open={controlled() ? local.forceOpen : state.open} onOpenChange={(open) => { - if (local.forceOpen) return + if (controlled()) return if (state.block && open) return if (justClickedTrigger) { justClickedTrigger = false