Apply PR #38790: feat(app): add workspace flows to new layout

This commit is contained in:
opencode-agent[bot] 2026-08-08 03:45:11 +00:00
commit 9bed65d50d
64 changed files with 3809 additions and 871 deletions

View file

@ -22,6 +22,8 @@
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
- Feature work adds English source strings only. Leave non-English keys absent so the runtime English fallback applies; translations land separately after language review.
- Render count-sensitive copy only through `language.plural(baseKey, count, params)`. Never select or pass `.zero`, `.one`, `.two`, `.few`, `.many`, or `.other` variants to `language.t(...)`.
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar.

View file

@ -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")

View file

@ -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()

View file

@ -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<string, unknown>) {
await page.addInitScript(
({ root, server, tab }) => {
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ projects: { local: [{ worktree: root, expanded: true }] }, lastProject: { local: root } }),
)
localStorage.setItem("opencode.window.browser.dat:tabs", JSON.stringify([{ server, ...tab }]))
},
{ root, server, tab },
)
}
test("selects an existing workspace from the start menu", async ({ page }) => {
const draftID = "draft_workspaces"
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: /Workspace/ }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
})
test("lists and manually deletes workspaces from settings", async ({ page }) => {
const draftID = "draft_workspace_settings"
const cleanWorkspace = `${workspace}-clean`
const inventory = { ...project, sandboxes: [cleanWorkspace] }
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project: inventory,
provider,
sessions: [],
pageMessages: () => ({ items: [] }),
})
let releaseSessions = () => {}
const sessionsReady = new Promise<void>((resolve) => {
releaseSessions = resolve
})
await page.route("**/session?**", async (route) => {
const url = new URL(route.request().url())
if (route.request().method() !== "GET" || url.pathname !== "/session" || url.searchParams.get("limit") !== "1000")
return route.fallback()
await sessionsReady
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "[]",
})
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "DELETE" },
})
return
}
if (route.request().method() !== "DELETE") return route.fallback()
await transport.send({
directory: "global",
payload: {
id: "evt_workspace_deleted_settings",
type: "project.updated",
properties: { ...inventory, sandboxes: [] },
},
})
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "true",
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
const app = page.locator('[data-component="session-new-design"]')
const appNode = await app.elementHandle()
await page.getByRole("button", { name: /local|new workspace/i }).click()
const workspacesTrigger = page.getByRole("menuitem", { name: /Workspace/ })
if (await workspacesTrigger.isVisible()) await workspacesTrigger.hover()
await page.getByRole("menuitem", { name: "View all" }).click()
const settings = page.locator(".settings-v2-dialog")
await expect(settings.getByRole("tab", { name: "Workspaces" })).toHaveAttribute("data-selected")
expect(await appNode?.evaluate((node) => node.isConnected)).toBe(true)
releaseSessions()
await expect(settings.getByText(cleanWorkspace, { exact: true })).toBeVisible()
await settings.getByRole("button", { name: 'Delete workspace "feature-clean"?' }).click()
const confirmation = page
.locator('[data-component="dialog-v2"]')
.filter({ hasText: 'Delete workspace "feature-clean"?' })
const removed = page.waitForRequest(
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/experimental/worktree",
)
await confirmation.getByRole("button", { name: "Delete workspace" }).click()
const request = await removed
expect(new URL(request.url()).searchParams.get("directory")).toBe(root)
expect(request.postDataJSON()).toEqual({ directory: cleanWorkspace })
await expect(settings.getByText(cleanWorkspace, { exact: true })).toHaveCount(0)
})
test("submits the owning prompt after a new workspace becomes ready", async ({ page }) => {
const draftID = "draft_workspace_submit"
const sessionID = "ses_workspace_submit"
const session = {
id: sessionID,
slug: "workspace-submit",
projectID: project.id,
directory: createdWorkspace,
title: "New session",
version: "dev",
time: { created: 1, updated: 2 },
}
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [] }),
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
})
})
await page.route("**/session**", async (route) => {
const url = new URL(route.request().url())
if (url.pathname !== "/session") return route.fallback()
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify(session),
})
})
await page.route(`**/session/${sessionID}/prompt_async**`, async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*" },
})
})
await init(page, { type: "draft", draftID, directory: root })
await page.goto(`/new-session?draftId=${draftID}`)
await transport.waitForConnection()
await page.getByRole("button", { name: /^local$/i }).click()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const editor = page.locator('[data-component="prompt-input"][contenteditable="true"]')
await editor.fill("Build workspace support")
await page.locator('[data-action="prompt-submit"]').click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
const sent = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === `/session/${sessionID}/prompt_async`,
)
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_submit_ready",
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await sent
await expect(lifecycle).toContainText("Workspace created")
})
test("moves a changed local session through workspace creation without changing lifecycle semantics", async ({
page,
}) => {
const sessionID = "ses_workspace_move_new"
const messageID = "msg_workspace_move_new"
const session = {
id: sessionID,
slug: "workspace-move-new",
projectID: project.id,
directory: root,
title: "Create a workspace",
version: "dev",
time: { created: 1, updated: 2 },
}
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, { server })
await mockOpenCodeServer(page, {
directory: root,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Create isolated workspace", true)] }),
vcsDiff: [diff],
})
await page.route("**/experimental/worktree**", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
if (route.request().method() !== "POST") return route.fallback()
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: JSON.stringify({ name: "quick-contrast-fix", directory: createdWorkspace, branch: "quick-contrast-fix" }),
})
})
await page.route("**/experimental/control-plane/move-session", async (route) => {
if (route.request().method() === "OPTIONS") {
await route.fulfill({
status: 204,
headers: { "access-control-allow-origin": "*", "access-control-allow-methods": "POST" },
})
return
}
session.directory = createdWorkspace
await route.fulfill({
status: 200,
contentType: "application/json",
headers: { "access-control-allow-origin": "*" },
body: "null",
})
})
await init(page, { type: "session", sessionId: sessionID })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
await transport.waitForConnection()
await page.locator("[data-session-title]").getByRole("button", { name: "Session details" }).click()
const panel = page.locator('[data-component="session-summary-panel"]')
await panel.getByRole("button", { name: "Local repository" }).click()
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
const moved = page.waitForRequest(
(request) =>
request.method() === "POST" && new URL(request.url()).pathname === "/experimental/control-plane/move-session",
)
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_worktree_ready",
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
expect((await moved).postDataJSON()).toEqual({
sessionID,
destination: { directory: createdWorkspace },
moveChanges: true,
})
await transport.send({
directory: createdWorkspace,
payload: {
id: "evt_workspace_created",
type: "session.next.moved",
properties: {
timestamp: Date.now(),
sessionID,
location: { directory: createdWorkspace },
subdirectory: "",
},
},
})
await expect(lifecycle).toContainText("Workspace created")
})

View file

@ -29,23 +29,38 @@ export type SseEventOptions = {
export type SseTransport<T> = {
server: string
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
waitForConnection(options?: {
after?: number
timeout?: number
path?: SseConnectionRecord["path"]
}): Promise<SseConnectionRecord>
send(payload: T, options?: SseEventOptions, path?: SseConnectionRecord["path"]): Promise<SseDeliveryAcknowledgement>
burst(payloads: readonly T[], options?: readonly SseEventOptions[]): Promise<SseDeliveryAcknowledgement[]>
split(payload: T, cuts: readonly number[], options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
heartbeat(options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
writeRaw(value: string | Uint8Array, cuts?: readonly number[], marker?: string): Promise<SseDeliveryAcknowledgement>
close(): Promise<void>
disconnect(message?: string): Promise<void>
error(message?: string): Promise<void>
error(message?: string, path?: SseConnectionRecord["path"]): Promise<void>
connections(): Promise<SseConnectionRecord[]>
acknowledgements(): Promise<SseDeliveryAcknowledgement[]>
}
type BrowserCommand<T> =
| { type: "send"; deliveries: { payload: T; options?: SseEventOptions }[]; burst: boolean; cuts?: number[] }
| {
type: "send"
deliveries: { payload: T; options?: SseEventOptions }[]
burst: boolean
cuts?: number[]
path?: SseConnectionRecord["path"]
}
| { type: "raw"; bytes: number[]; cuts?: number[]; marker?: string }
| { type: "end"; mode: "close" | "disconnect" | "error"; message?: string }
| {
type: "end"
mode: "close" | "disconnect" | "error"
message?: string
path?: SseConnectionRecord["path"]
}
| { type: "connections" }
| { type: "acknowledgements" }
@ -73,7 +88,8 @@ export async function installSseTransport<T>(
let nextConnectionID = 0
let nextDeliveryID = 0
const current = () => connections.findLast((connection) => connection.endedAt === undefined)
const current = (path?: SseConnectionRecord["path"]) =>
connections.findLast((connection) => connection.endedAt === undefined && (!path || connection.path === path))
const chunks = (bytes: Uint8Array, cuts?: readonly number[]) => {
const boundaries = [...new Set(cuts ?? [])]
.filter((cut) => Number.isInteger(cut) && cut > 0 && cut < bytes.byteLength)
@ -125,8 +141,8 @@ export async function installSseTransport<T>(
acknowledgements.push(acknowledgement)
return acknowledgement
}
const end = (mode: "close" | "disconnect" | "error", message?: string) => {
const connection = current()
const end = (mode: "close" | "disconnect" | "error", message?: string, path?: SseConnectionRecord["path"]) => {
const connection = current(path)
if (!connection) throw new Error("SSE transport has no active connection")
connection.endedAt = performance.now()
connection.endedBy = mode
@ -146,8 +162,8 @@ export async function installSseTransport<T>(
if (input.type === "connections")
return connections.map(({ controller: _controller, ...connection }) => connection)
if (input.type === "acknowledgements") return acknowledgements
if (input.type === "end") return end(input.mode, input.message)
const connection = current()
if (input.type === "end") return end(input.mode, input.message, input.path)
const connection = current(input.type === "send" ? input.path : undefined)
if (!connection) throw new Error("SSE transport has no active connection")
if (input.type === "raw") {
marker(input.marker)
@ -248,12 +264,15 @@ export async function installSseTransport<T>(
server,
async waitForConnection(input = {}) {
const connection = await page.waitForFunction(
(after) => {
({ after, path }) => {
const transport = (window as BrowserTransport).__testSseTransport
const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined
return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined)
return connections?.findLast(
(connection) =>
connection.id > after && connection.endedAt === undefined && (!path || connection.path === path),
)
},
input.after ?? 0,
{ after: input.after ?? 0, path: input.path },
{ timeout: input.timeout },
)
let result: SseConnectionRecord | undefined
@ -265,8 +284,8 @@ export async function installSseTransport<T>(
if (!result) throw new Error("SSE transport connection disappeared while waiting")
return result
},
send(payload, eventOptions) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false })
send(payload, eventOptions, path) {
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, path })
},
burst(payloads, eventOptions = []) {
return command({
@ -304,8 +323,8 @@ export async function installSseTransport<T>(
disconnect(message) {
return command({ type: "end", mode: "disconnect", message })
},
error(message) {
return command({ type: "end", mode: "error", message })
error(message, path) {
return command({ type: "end", mode: "error", message, path })
},
connections() {
return command({ type: "connections" })

View file

@ -1,6 +1,7 @@
import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context"
import type { UiI18n } from "@opencode-ai/ui/context/i18n"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
@ -235,7 +236,13 @@ function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return (
<I18nProvider
value={{ locale: language.intl, layoutLocale: language.layoutLocale, t: language.t, plural: language.plural }}
value={{
locale: language.intl,
layoutLocale: language.layoutLocale,
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
}}
>
{props.children}
</I18nProvider>

View file

@ -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() {
<p>{language.t("help.tabs.home")}</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>{language.t("help.tabs.persistence")}</p>
<p>{language.t("help.tabs.worktrees")}</p>
</div>
</div>
</DrawerContent>

View file

@ -37,6 +37,7 @@ export type PromptInputV2ComposerProps = {
class?: string
controller: PromptInputV2ComposerController
borderUnderlay?: boolean
accentSubmit?: boolean
}
export type PromptInputV2ControllerProps = Omit<PromptInputProps, "class" | "submission">
@ -53,6 +54,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
<div class="flex flex-col gap-3">
<PromptInputV2
controller={props.controller}
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.controller.model.loading}

View file

@ -1,18 +1,18 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
import { createStore } from "solid-js/store"
import type { Prompt, PromptStore } from "@/context/prompt"
import type { ModelSelection } from "@/context/local"
import { Worktree } from "@/utils/worktree"
import { WorkspaceOperation } from "@/utils/workspace-operation"
import { ServerScope } from "@/utils/server-scope"
let createPromptSubmit: typeof import("./submit").createPromptSubmit
const createdClients: string[] = []
const createdSessions: string[] = []
const sessionCreateInputs: Array<{
type SessionCreateInput = {
agent?: string
model?: { id: string; providerID: string; variant?: string }
location?: { directory: string }
}> = []
const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = []
}
const optimistic: Array<{
directory?: string
sessionID?: string
@ -22,24 +22,35 @@ const optimistic: Array<{
variant?: string
}
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = []
const sentShellDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
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<void> | undefined
let createWorktreeGate: Promise<void> | undefined
let worktreeFailure: Error | undefined
let worktreeHung = false
let worktreeCreates = 0
let activeSDK = "server-a"
let activeServerSync = "server-a"
let activeDirectorySync = "server-a"
let commands: Array<{ name: string }> = []
let worktreeDirectory = "/repo/new-0"
let worktreeID = 0
const draftServers: Record<string, string> = {}
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore<PromptStore>({
@ -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<never>((_, 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<Parameters<typeof createPromptSubmit>[0]> = {}) =>
createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
...overrides,
})
describe("prompt submit worktree selection", () => {
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
const submit = makeSubmit()
const event = { preventDefault: () => undefined } as unknown as Event
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
await submit.handleSubmit(event)
selected = "/repo/worktree-b"
await submit.handleSubmit(event)
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
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<void>((resolve) => {
release = resolve
})
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => true,
mode: () => "shell",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
let submitted = 0
const submit = makeSubmit({
onSubmit: () => submitted++,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
permissionServer = "server-b"
const result = submit.handleSubmit(event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
test("promotes drafts using the selected project's server", async () => {
search = { draftId: "draft-1" }
const submit = createPromptSubmit({
prompt,
info: () => undefined,
imageAttachments: () => [],
commentCount: () => 0,
autoAccept: () => false,
mode: () => "normal",
working: () => false,
editor: () => undefined,
queueScroll: () => undefined,
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
addToHistory: () => undefined,
resetHistoryNavigation: () => undefined,
setMode: () => undefined,
setPopover: () => undefined,
newSessionWorktree: () => selected,
onNewSessionWorktreeReset: () => undefined,
onSubmit: () => undefined,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server", sessionId: "session-1" }])
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
expect(optimisticServers).toEqual(["server-a"])
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
expect(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",
})
})
})

View file

@ -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<string, PendingPrompt>()
const submitting = new Set<string>()
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<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
resolve({ status: "failed", message: "aborted" })
return
}
controller.signal.addEventListener(
"abort",
() => {
resolve({ status: "failed", message: "aborted" })
},
{ once: true },
)
})
const timeoutMs = 5 * 60 * 1000
const timer = { id: undefined as number | undefined }
const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})
const result = await Promise.race([
WorktreeState.wait(submissionScope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
pending.delete(key)
if (pendingSubmission?.key === key) pendingSubmission = undefined
if (timer.id === undefined) return
clearTimeout(timer.id)
})
if (controller.signal.aborted) return false
if (result.status === "failed") {
WorkspaceOperation.fail(submissionScope, session.id)
throw new Error(result.message)
}
WorkspaceOperation.complete(submissionScope, session.id)
return true
}
if (!draftID || search.draftId === draftID) onSubmit?.()
if (mode === "shell") {
clearInput()
const eventID = Event.ID.create()
startWorkspaceOperation(eventID)
void waitForWorktree(() => {
restoreInput()
})
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<Awaited<ReturnType<typeof WorktreeState.wait>>>((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<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
timer.id = window.setTimeout(() => {
resolve({
status: "failed",
message: language.t("workspace.error.stillPreparing"),
})
}, timeoutMs)
})
const result = await Promise.race([
WorktreeState.wait(sdk().scope, sessionDirectory),
abortWait,
timeout,
]).finally(() => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(pendingKey(session.id))
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
} 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 {

View file

@ -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 (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<IconV2 name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<IconV2 name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
<TooltipV2
placement="top"
value={
props.onboarding ? (
<div class="flex flex-col gap-1 text-left">
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
<span>{language.t("workspace.onboarding.title")}</span>
</div>
<span class="font-[440] text-v2-text-text-muted">
{language.t("workspace.onboarding.description")}
</span>
</div>
) : (
language.t("session.new.workspace.trigger.tooltip")
)
}
contentClass={props.onboarding ? "max-w-[280px]" : undefined}
class="min-w-0"
>
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
<MenuV2.Trigger
aria-description={language.t("session.new.workspace.trigger.tooltip")}
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
>
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{label()}</span>
<Show when={props.onboarding}>
<span
data-slot="workspace-onboarding-dot"
aria-hidden="true"
class="size-1.5 shrink-0 rounded-full bg-v2-text-text-accent"
/>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
<PromptGitStatus branch={props.branch} />
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class="w-[200px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<Icon name="monitor" />
<TooltipV2
placement="right"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("session.new.workspace.local")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.local.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
</TooltipV2>
<Show when={selected() === "main"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
<MenuV2.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<TooltipV2
placement="right"
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</TooltipV2>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
</MenuV2.Group>
<Show
when={props.workspaces.length > 0}
fallback={
<>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</>
}
>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub
gutter={0}
overlap
overflowPadding={8}
onOpenChange={(open) => {
if (!open) {
focusSearch = false
return
}
if (!focusSearch || props.workspaces.length < 10) return
focusSearch = false
requestAnimationFrame(() => searchInput?.focus())
}}
>
<MenuV2.SubTrigger
onKeyDown={(event) => {
if (
event.key === "ArrowRight" ||
event.key === "ArrowLeft" ||
event.key === "Enter" ||
event.key === " "
)
focusSearch = true
}}
>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</span>
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<Show when={props.workspaces.length >= 10}>
<div class="flex h-7 items-center gap-2 rounded-sm pl-3 pr-2 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
searchInput = element
}}
value={search()}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
</div>
</Show>
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item onSelect={() => select(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
<Show when={selected() === workspace}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
</MenuV2.Item>
)}
</For>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Item onSelect={() => (pending = { type: "viewAll" })}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</TooltipV2>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ml-1" />
</>
)
}
export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
if (props.from) return language.t("session.new.workspace.fromBranch", { branch: props.branch })
return props.branch
}
const icon = () => {
if (props.noGit) return "monitor"
if (props.from) return "branch-out"
return "branch"
}
return (
<Show when={label()}>
{(value) => (
<>
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
<TooltipV2
placement="top"
value={value()}
class="min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
</>
<TooltipV2
placement="top"
value={value()}
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
<Icon
name={icon()}
size="small"
class="shrink-0 text-v2-icon-icon-muted"
/>
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>
)}
</Show>
)

View file

@ -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<typeof MenuV2>["placement"]
gutter?: number
class?: string
contentClass?: string
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const openWorkspaces = useSettingsDialog("workspaces")
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
const blocked = () =>
props.eligible === false || operationPending() || serverSync().session.data.session_working(props.sessionID)
const workspaces = () =>
workspaceDirectories(props.project).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
const fail = (scope: ServerScope, sessionID: string, message: string) => {
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
WorkspaceOperation.fail(scope, sessionID)
showToast({ variant: "error", title: language.t("workspace.move.failed"), description: message })
}
const move = async (selection: "create" | string) => {
if (store.selected || blocked()) return
const sdk = serverSDK()
const sync = serverSync()
const scope = sdk.scope
const sessionID = props.sessionID
const messageID = props.messageID
const root = props.project.worktree
const source = props.directory
setStore("selected", selection)
try {
const destination =
selection === "create"
? await createWorkspace(root, sessionID, messageID, sdk, (message) => fail(scope, sessionID, message), {
createFailed: language.t("prompt.toast.worktreeCreateFailed.title"),
stillPreparing: language.t("workspace.error.stillPreparing"),
})
: selection
if (!destination) return
WorkspaceOperation.start(scope, sessionID, selection === "create" ? "create" : "move", destination, messageID)
if (sync.session.data.session_working(sessionID)) throw new Error(language.t("workspace.move.failed"))
await workspaceRequestWithTimeout(
(signal) =>
sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory: destination },
moveChanges: true,
},
{ signal },
),
language.t("workspace.move.failed"),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
const session = await workspaceRequestWithTimeout(
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
language.t("workspace.move.failed"),
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
)
if (!session || pathKey(session.directory) !== pathKey(destination))
throw new Error(language.t("workspace.move.failed"))
WorkspaceOperation.complete(scope, sessionID, destination)
sync.reindexSession(sessionID, source)
} catch (error) {
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed"))
} finally {
setStore("selected", undefined)
}
}
return (
<MenuV2
placement={props.placement ?? "bottom-end"}
gutter={props.gutter ?? 4}
modal={false}
onOpenChange={props.onOpenChange}
>
<MenuV2.Trigger class={props.class} disabled={blocked()}>
{props.children}
</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content class={`w-[200px] ${props.contentClass ?? ""}`}>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.menu.title")}</MenuV2.GroupLabel>
<Show when={pathKey(props.directory) !== pathKey(props.project.worktree)}>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(props.project.worktree)}>
<Icon name="monitor" />
{language.t("session.new.workspace.local")}
</MenuV2.Item>
</Show>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
<Show when={workspaces().length > 0}>
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace-isolated" />
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-h-[calc(100dvh-16px)] w-[200px] overflow-y-auto">
<For each={workspaces()}>
{(workspace) => (
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
<Icon name="workspace-isolated" />
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
</MenuV2.Item>
)}
</For>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
</Show>
</MenuV2.Group>
<MenuV2.Separator class="h-[0.5px] bg-v2-border-border-base" />
<MenuV2.Item onSelect={() => openWorkspaces()}>
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
}
async function createWorkspace(
root: string,
sessionID: string,
messageID: string | undefined,
serverSDK: ReturnType<ReturnType<typeof useServerSDK>>,
fail: (message: string) => void,
messages: { createFailed: string; stillPreparing: string },
) {
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", root, messageID)
const created = await workspaceRequestWithTimeout(
(signal) => serverSDK.client.worktree.create({ directory: root }, { signal }),
messages.createFailed,
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then((result) => result.data)
.catch((error) => {
fail(error instanceof Error ? error.message : messages.createFailed)
return undefined
})
if (!created?.directory) return
WorkspaceOperation.start(serverSDK.scope, sessionID, "create", created.directory, messageID)
Worktree.pending(serverSDK.scope, created.directory)
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
const timeout = new Promise<ReturnType<typeof Worktree.get>>((resolve) => {
timer.id = setTimeout(
() => resolve({ status: "failed", message: messages.stillPreparing }),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
})
const ready = await Promise.race([Worktree.wait(serverSDK.scope, created.directory), timeout]).finally(() => {
if (timer.id) clearTimeout(timer.id)
})
if (!ready || ready.status === "failed") {
fail(ready?.message ?? messages.createFailed)
return
}
return created.directory
}

View file

@ -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<{
<TabsV2.SectionTitle>{language.t("settings.section.desktop")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="general">
<Icon name="sliders" />
<Icon name="sliders" size="small" />
{language.t("settings.tab.general")}
</TabsV2.Trigger>
<TabsV2.Trigger value="shortcuts">
<Icon name="keyboard" />
<Icon name="keyboard" size="small" />
{language.t("settings.tab.shortcuts")}
</TabsV2.Trigger>
</div>
@ -71,16 +69,20 @@ export const DialogSettings: Component<{
<div class="flex flex-col gap-1.5">
<TabsV2.SectionTitle>{language.t("settings.section.server")}</TabsV2.SectionTitle>
<div class="flex flex-col gap-1.5 w-full">
<TabsV2.Trigger value="workspaces">
<Icon name="workspace-isolated" size="small" />
{language.t("settings.tab.workspaces")}
</TabsV2.Trigger>
<TabsV2.Trigger value="servers">
<Icon name="server" />
<Icon name="server" size="small" />
{language.t("status.popover.tab.servers")}
</TabsV2.Trigger>
<TabsV2.Trigger value="providers">
<Icon name="providers" />
<Icon name="providers" size="small" />
{language.t("settings.providers.title")}
</TabsV2.Trigger>
<TabsV2.Trigger value="models">
<Icon name="models" />
<Icon name="models" size="small" />
{language.t("settings.models.title")}
</TabsV2.Trigger>
</div>
@ -99,6 +101,9 @@ export const DialogSettings: Component<{
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
</TabsV2.Content>
<TabsV2.Content value="workspaces" class="settings-v2-panel">
<SettingsWorkspacesV2 activeDirectory={directory()} />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>

View file

@ -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 (
<SettingsRowV2
title={language.t("settings.workspaces.default.title")}
description={language.t("settings.workspaces.default.description")}
>
<SelectV2
appearance="inline"
options={options()}
current={options().find((option) => option.value === settings.workspaces.defaultDestination())}
value={(option) => option.value}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && settings.workspaces.setDefaultDestination(option.value)}
/>
</SettingsRowV2>
)
}
const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => {
const language = useLanguage()
const options = createMemo(() =>
@ -329,6 +357,7 @@ export const SettingsGeneralV2: Component<{
<SettingsListV2>
<LanguageSetting />
<WorkspaceDestinationSetting />
<PermissionScopeSetting controller={permissionScope} />
<ShellSetting controller={shell} />
@ -391,18 +420,6 @@ export const SettingsGeneralV2: Component<{
<h3 class="settings-v2-section-title">{language.t("settings.general.section.advanced")}</h3>
<SettingsListV2>
<SettingsRowV2
title={language.t("settings.general.row.showFileTree.title")}
description={language.t("settings.general.row.showFileTree.description")}
>
<div data-action="settings-show-file-tree">
<Switch
checked={settings.general.showFileTree()}
onChange={(checked) => settings.general.setShowFileTree(checked)}
/>
</div>
</SettingsRowV2>
<SettingsRowV2
title={language.t("settings.general.row.showSearch.title")}
description={language.t("settings.general.row.showSearch.description")}

View file

@ -684,6 +684,223 @@
color: var(--v2-text-text-base);
}
.settings-v2-tab-header.settings-v2-workspaces-header {
padding-bottom: 24px;
}
.settings-v2-workspaces-header .settings-v2-tab-title {
font-weight: 610;
}
.settings-v2-tab-body.settings-v2-workspaces {
gap: 16px;
}
.settings-v2-workspaces-toolbar {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.settings-v2-workspaces-count {
font-size: 15px;
font-weight: 530;
line-height: 1;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-toolbar-actions {
display: flex;
align-items: center;
gap: 4px;
}
.settings-v2-workspaces-delete-all {
color: var(--v2-state-fg-danger);
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
display: flex;
flex-direction: column;
gap: 0;
padding: 20px;
border-radius: 6px;
background-color: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
}
.settings-v2-workspaces-row {
display: flex;
min-width: 0;
flex-direction: column;
gap: 12px;
}
.settings-v2-workspaces-row:not(:last-child) {
padding-bottom: 20px;
margin-bottom: 20px;
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-row-header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.settings-v2-workspaces-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 8px;
}
.settings-v2-workspaces-main {
display: flex;
min-width: 0;
}
.settings-v2-workspaces-row-actions {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.settings-v2-workspaces-main [data-component="tooltip-v2-trigger"] {
min-width: 0;
}
.settings-v2-workspaces-path {
display: block;
min-width: 0;
overflow: hidden;
color: var(--v2-text-text-base);
font-family: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
text-align: left;
cursor: default;
}
.settings-v2-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-active,
.settings-v2-workspaces-more {
flex-shrink: 0;
font-size: 11px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-faint);
}
.settings-v2-workspaces-sessions {
display: flex;
flex-direction: column;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 4px;
background-color: var(--v2-background-bg-base);
overflow: hidden;
}
.settings-v2-workspaces-session {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
font-size: 13px;
font-weight: 440;
line-height: 16px;
color: var(--v2-text-text-base);
}
.settings-v2-workspaces-session:not(:last-child) {
border-bottom: 0.5px solid var(--v2-border-border-base);
}
.settings-v2-workspaces-session > span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-v2-workspaces-session-time {
flex-shrink: 0;
font-size: 11px;
line-height: 1;
color: var(--v2-text-text-muted);
}
.settings-v2-workspaces-empty {
display: flex;
align-items: center;
justify-content: center;
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
color: var(--v2-text-text-muted);
}
@media (max-width: 639px) {
.settings-v2-workspaces-header {
padding: 24px 20px 20px;
}
.settings-v2-tab-body.settings-v2-workspaces {
padding: 0 20px 24px;
}
.settings-v2-workspaces-toolbar,
.settings-v2-workspaces-main {
align-items: flex-start;
}
.settings-v2-workspaces-toolbar {
flex-wrap: wrap;
}
.settings-v2-workspaces-toolbar-actions {
width: 100%;
flex-wrap: wrap;
justify-content: space-between;
}
.settings-v2-workspaces-inventory [data-component="settings-v2-list"] {
padding: 14px;
}
.settings-v2-workspaces-path {
overflow: visible;
text-overflow: clip;
white-space: normal;
overflow-wrap: anywhere;
}
.settings-v2-workspaces-active {
display: none;
}
}
[data-component="dialog-v2"].settings-v2-server-dialog [data-slot="dialog-container"] {
width: 480px;
max-width: calc(100vw - 32px);

View file

@ -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<void>) => {
if (store.transaction !== "confirm") return
setStore("transaction", "running")
try {
await task()
} catch (error) {
showToast({
variant: "error",
title: language.t("workspace.delete.failed.title"),
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
})
} finally {
setStore("transaction", undefined)
}
}
const confirmDelete = (workspace: Workspace) => {
if (store.transaction) return
const context = captureDeleteContext()
const current = ++inspectionID
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteWorkspace
workspace={workspace}
scope={context.sdk.scope}
inspectionID={current}
inspect={() => inspect(workspace, context)}
inspectionMessage={inspectionMessage}
onDelete={() => transact(() => remove(workspace, true, context))}
/>
),
releaseConfirmation,
)
}
const removeAll = async (inventory: Workspace[], context: ReturnType<typeof captureDeleteContext>) => {
await removeWorkspacesSequentially(inventory, (workspace) => remove(workspace, false, context))
}
const confirmDeleteAll = () => {
if (store.transaction) return
const context = captureDeleteContext()
const inventory = [...filtered()]
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
setStore("transaction", "confirm")
void dialog.push(
() => (
<DialogDeleteAllWorkspaces
count={inventory.length}
project={project}
onDelete={() => transact(() => removeAll(inventory, context))}
/>
),
releaseConfirmation,
)
}
return (
<>
<div class="settings-v2-tab-header settings-v2-workspaces-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.workspaces")}</h2>
</div>
<div class="settings-v2-tab-body settings-v2-workspaces">
<div class="settings-v2-workspaces-toolbar">
<span class="settings-v2-workspaces-count">
{language.plural("settings.workspaces.count", filtered().length)}
</span>
<div class="settings-v2-workspaces-toolbar-actions">
<Show when={projects().length > 1}>
<SelectV2
appearance="inline"
options={projectOptions()}
current={projectOptions().find((option) => option.id === selectedProject())}
value={(option) => option.id}
label={(option) => option.label}
placement="bottom-end"
gutter={6}
onSelect={(option) => option && setStore("project", option.id)}
/>
</Show>
<Show when={filtered().length > 0}>
<MenuV2 placement="bottom-end" gutter={4}>
<MenuV2.Trigger
as={IconButtonV2}
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("common.moreOptions")}
disabled={!!store.transaction}
icon={<Icon name="outline-dots" size="small" />}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={confirmDeleteAll}>
<span class="settings-v2-workspaces-delete-all">
{language.t("settings.workspaces.deleteAll")}
</span>
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</Show>
</div>
</div>
<div class="settings-v2-workspaces-inventory">
<Show
when={filtered().length > 0}
fallback={<div class="settings-v2-workspaces-empty">{language.t("settings.workspaces.empty")}</div>}
>
<SettingsListV2>
<For each={filtered()}>
{(workspace) => {
const linked = () => workspaceSessions(workspace)
return (
<div class="settings-v2-workspaces-row">
<div class="settings-v2-workspaces-row-header">
<div class="settings-v2-workspaces-copy">
<div class="settings-v2-workspaces-main">
<TooltipV2
value={workspace.directory}
placement="top-start"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span tabIndex={0} aria-label={workspace.directory} class="settings-v2-workspaces-path">
{workspace.directory}
</span>
</TooltipV2>
</div>
<span class="settings-v2-workspaces-meta">{sessionCount(workspace)}</span>
</div>
<div class="settings-v2-workspaces-row-actions">
<Show when={lastActive(workspace)}>
{(value) => (
<TooltipV2
value={language.t("settings.workspaces.lastActiveSession")}
placement="top-end"
>
<span tabIndex={0} class="settings-v2-workspaces-active">
{value()}
</span>
</TooltipV2>
)}
</Show>
<IconButtonV2
type="button"
variant="ghost-muted"
size="small"
aria-label={language.t("workspace.delete.confirm", {
name: getFilename(workspace.directory),
})}
disabled={!!store.transaction}
icon={<Icon name="trash" size="small" />}
onClick={() => confirmDelete(workspace)}
/>
</div>
</div>
<Show when={linked().length > 0}>
<div class="settings-v2-workspaces-sessions">
<For each={linked()}>
{(session) => (
<div class="settings-v2-workspaces-session">
<span>{session.title}</span>
<Show when={sessionTime(session)}>
{(time) => <span class="settings-v2-workspaces-session-time">{time()}</span>}
</Show>
</div>
)}
</For>
</div>
</Show>
</div>
)
}}
</For>
</SettingsListV2>
</Show>
</div>
</div>
</>
)
}
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
const dialog = useDialog()
const language = useLanguage()
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("settings.workspaces.deleteAll")}
description={
<>
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
<br />
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 type="button" variant="danger" onClick={remove}>
{language.t("settings.workspaces.deleteAll")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}
function DialogDeleteWorkspace(props: {
workspace: Workspace
scope: ServerScope
inspectionID: number
inspect: () => Promise<{ result: WorkspaceDeleteInspection; sessions: Session[] }>
inspectionMessage: (result: WorkspaceDeleteInspection) => string
onDelete: () => Promise<void>
}) {
const dialog = useDialog()
const language = useLanguage()
const status = useQuery(() => ({
queryKey: [props.scope, pathKey(props.workspace.directory), "workspace-delete-status", props.inspectionID] as const,
queryFn: props.inspect,
staleTime: 0,
}))
const description = () => {
if (status.isPending) return language.t("workspace.status.checking")
if (status.isError) return language.t("workspace.status.error")
return props.inspectionMessage(status.data?.result ?? "unknown")
}
const remove = () => {
const deleting = props.onDelete()
dialog.close()
void deleting
}
return (
<Dialog fit>
<DialogHeader>
<DialogTitleGroup
title={language.t("workspace.delete.title")}
description={
<>
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
<br />
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
{props.workspace.directory}
</code>
<br />
{language.t("settings.workspaces.delete.warning")}
<br />
{description()}
</>
}
/>
</DialogHeader>
<DialogFooter>
<ButtonV2 type="button" variant="neutral" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2
type="button"
variant="danger"
disabled={
status.isPending || status.isError || (status.data?.result !== "safe" && status.data?.result !== "dirty")
}
onClick={remove}
>
{language.t("workspace.delete.button")}
</ButtonV2>
</DialogFooter>
</Dialog>
)
}

View file

@ -2,7 +2,12 @@ import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import {
pluralCategory,
type UiI18nPluralLookupKey,
type UiI18nPluralKey,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
@ -28,11 +33,13 @@ function localeDirection(locale: Locale): Direction {
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
type PluralKey =
| UiI18nPluralKey
| "session.question.pending"
| "session.followupDock.summary"
| "session.revertDock.summary"
type AppI18nKey = Extract<keyof typeof en, string>
type AppI18nPluralKey = {
[Key in AppI18nKey]: Key extends `${infer Base}.other` ? (`${Base}.one` extends AppI18nKey ? Base : never) : never
}[AppI18nKey]
type PluralKey = AppI18nPluralKey | UiI18nPluralKey
type AppI18nPluralLookupKey = `${AppI18nPluralKey}.${UiPluralCategory}`
type TranslationKey<Key extends string> = Key extends AppI18nPluralLookupKey | UiI18nPluralLookupKey ? never : Key
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) {
@ -189,18 +196,23 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as <Key extends string>(
key: TranslationKey<Key>,
params?: Record<string, string | number | boolean>,
) => string
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) => {
const category = pluralCategory(intl(), count)
const pluralForm = (
key: PluralKey,
category: UiPluralCategory,
params?: Record<string, string | number | boolean>,
) => {
const current = (dict.loading ? base : (dict() ?? base)) as Record<string, string>
const candidate = `${key}.${category}`
const fallback = `${key}.other`
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, { ...params, count })
return i18n.resolveTemplate(current[candidate] ?? current[fallback] ?? fallback, params)
}
const plural = (key: PluralKey, count: number, params?: Record<string, string | number | boolean>) =>
pluralForm(key, pluralCategory(intl(), count), { ...params, count })
const label = (value: Locale) => DESKTOP_NATIVE_LABELS[value]
@ -231,6 +243,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
label,
t,
plural,
pluralForm,
setLocale(next: Locale) {
setStore("locale", normalizeLocale(next))
},

View file

@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import type { ServerScope } from "@/utils/server-scope"
import {
createPromptReady,
createPromptSession,
@ -104,11 +105,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
const load = (scope: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) => {
const current = settings.general.newLayoutDesigns()
? selectPromptTab(tabs.store, scope, target?.server ?? serverKey())
: undefined
if (current) return createTabPromptState(tabs, current, target?.scope ?? serverSDK().scope, scope)
const key = scopeKey(scope)
const key = target ? `${target.scope}:${scopeKey(scope)}` : scopeKey(scope)
const existing = cache.get(key)
if (existing) {
cache.delete(key)
@ -118,7 +121,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK().scope, scope),
value: createPromptSession(target?.scope ?? serverSDK().scope, scope),
dispose,
}),
owner,
@ -130,7 +133,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const pick = (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
scope ? load(scope, target) : session()
const ready = createPromptReady(session)
const withSuspense = <T,>(cb: () => T): (() => T) =>
@ -146,7 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
return {
ready,
capture: (scope?: PromptScope) => pick(scope).capture(),
capture: (scope?: PromptScope, target?: { server?: ServerConnection.Key; scope: ServerScope }) =>
pick(scope, target).capture(),
current: withSuspense(() => session().current()),
cursor: withSuspense(() => session().cursor()),
dirty: withSuspense(() => session().dirty()),

View file

@ -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", () => {

View file

@ -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<void>((resolve) => setTimeout(resolve, ms))
let attempt: AbortController | undefined
let worktreeAttempt: AbortController | undefined
let run: Promise<void> | 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

View file

@ -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") })

View file

@ -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)))
},

View file

@ -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)

View file

@ -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) => {

View file

@ -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<string, WorkspaceLastUsed>
}
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) {

View file

@ -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<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
const existing = store.find((item) => tabKey(item) === tabKey(next))

View file

@ -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",

View file

@ -102,10 +102,10 @@ describe("i18n parity", () => {
const source = await dictionary(domain.source)
for (const locale of domain.locales) {
const target = await dictionary(domain.target(locale))
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
const extra = Object.keys(target)
.filter((key) => !Object.hasOwn(source, key))
.sort()
const missing = Object.keys(source).filter((key) => !Object.hasOwn(target, key))
const expected = pluralFamilies(source)
.flatMap((key) => (pluralCategories.get(locale) ?? []).map((category) => `${key}.${category}`))
.sort()
@ -200,7 +200,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)) {

View file

@ -327,4 +327,9 @@
animation-range: 0 0.1px;
}
}
body[data-new-layout] [data-slot="session-turn-diffs-header"] {
height: 24px;
padding-block: 0;
}
}

View file

@ -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({

View file

@ -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(() => <x.DialogSettings />)
dialog.show(() => <x.DialogSettings sessionID={params.id} />)
})
}

View file

@ -1,7 +1,10 @@
import { createPromptProjectController } from "@/components/prompt-project-selector"
import { useSettingsDialog } from "@/components/settings-dialog"
import { useTitlebarRightMount } from "@/components/titlebar"
import { useSettings } from "@/context/settings"
import { createEffect, createResource } from "solid-js"
import { useTabs, type DraftTab } from "@/context/tabs"
import { useSearchParams } from "@solidjs/router"
import { createEffect, createMemo, createResource } from "solid-js"
import { createNewSessionDraftController } from "./new-session/new-session-draft-controller"
import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view"
import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller"
@ -11,10 +14,23 @@ import { useNewSessionCommands } from "./new-session/use-new-session-commands"
export default function NewSessionPage() {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const workspace = createNewSessionWorkspaceController()
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
const openWorkspaces = useSettingsDialog("workspaces")
const draftTab = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
onViewAll: openWorkspaces,
})
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
onSubmit: workspace.selection.remember,
})
const project = createPromptProjectController({
controls: draft.project.controls,

View file

@ -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(() => {

View file

@ -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 (
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
<div
@ -41,7 +50,7 @@ export function NewSessionView(props: {
<div class={NEW_SESSION_CONTENT_WIDTH}>
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
<div class="mt-8 flex flex-col gap-8">
<PromptInputV2Composer controller={props.input} />
<PromptInputV2Composer controller={props.input} accentSubmit={props.workspace.selection.workspace()} />
<Show when={props.project.empty()}>
<PromptProjectAddButton controller={props.project} />
</Show>
@ -59,8 +68,10 @@ export function NewSessionView(props: {
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branch={props.workspace.bar.branch()}
onChange={props.workspace.selection.set}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onDone={props.input.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
</Show>
</div>
@ -137,7 +148,7 @@ function ProviderTip() {
>
<span class="truncate">{language.t("home.providerTip")}</span>
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
<IconV2 name="chevron-down" size="small" class="-rotate-90" />
<Icon name="chevron-down" size="small" class="-rotate-90" />
</span>
</button>
<TooltipV2
@ -152,7 +163,7 @@ function ProviderTip() {
aria-label={language.t("common.dismiss")}
onClick={() => setPersistedState("dismissedAt", Date.now())}
>
<IconV2 name="xmark-small" />
<Icon name="xmark-small" />
</button>
</TooltipV2>
</div>

View file

@ -1,20 +1,28 @@
import { createMemo, createSignal } from "solid-js"
import { createMemo } from "solid-js"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useServerSync } from "@/context/server-sync"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
import { pathKey } from "@/utils/path-key"
import {
isWorkspaceDirectory,
isWorkspaceSelection,
workspaceDefaultSelection,
workspaceDirectories,
} from "@/utils/workspace"
export function resolveNewSessionWorktree(input: {
enabled: boolean
selected?: string
directory: string
projectWorktree?: string
fallback?: string
}) {
if (!input.enabled) return "main"
if (input.selected) return input.selected
if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory
return "main"
return input.fallback ?? "main"
}
export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) {
@ -31,18 +39,38 @@ export function resolveNewSessionBranch(input: {
return input.worktreeBranch(input.worktree) ?? input.local
}
export function createNewSessionWorkspaceController() {
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const [worktree, setWorktree] = createSignal<string>()
const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const settings = useSettings()
const visible = createMemo(() => sync().project?.vcs === "git")
const selected = createMemo(() => {
const project = sync().project
const worktree = input.selected()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const fallback = createMemo(() => {
const project = sync().project
if (!project) return "main"
return workspaceDefaultSelection(
settings.workspaces.defaultDestination(),
settings.workspaces.lastUsed(serverSDK().scope, project.id),
)
})
const value = createMemo(() =>
resolveNewSessionWorktree({
enabled: visible(),
selected: worktree(),
selected: selected(),
directory: sdk().directory,
projectWorktree: sync().project?.worktree,
fallback: fallback(),
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
@ -54,18 +82,36 @@ export function createNewSessionWorkspaceController() {
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
}),
)
const remember = (worktree = value()) => {
const project = sync().project
if (!project) return
const local = worktree === "main" || pathKey(worktree) === pathKey(project.worktree)
settings.workspaces.setLastUsed(serverSDK().scope, project.id, local ? "local" : "workspace")
}
return {
selection: {
value,
reset: () => setWorktree(),
set: (worktree: string) =>
setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)),
workspace: createMemo(() => {
const project = sync().project
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => input.setSelected(undefined),
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
remember(worktree)
},
},
project: {
root: projectRoot,
workspaces: () => sync().project?.sandboxes ?? [],
workspaces: () => {
const project = sync().project
return project ? workspaceDirectories(project) : []
},
git: () => sync().project?.vcs === "git",
openAll: input.onViewAll,
},
bar: {
visible,

View file

@ -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)}
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@ -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 <PromptInputV2Composer controller={controller} borderUnderlay />
return (
<PromptInputV2Composer controller={controller} borderUnderlay accentSubmit={workspaceSession()} />
)
}}
</Show>
}

View file

@ -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
}

View file

@ -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())}
</>
}
>

View file

@ -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")}
</span>
</Show>
{props.action}
</div>
<div data-component="session-turn-diffs-content">
<Accordion
@ -224,6 +236,178 @@ function TimelineDiffSummaryRow(props: { diffs: SummaryDiff[] }) {
)
}
function WorkspaceLocationLoader() {
const dots = ["left-0 top-0", "right-0 top-0", "left-0 bottom-0", "right-0 bottom-0"]
return (
<span data-component="workspace-location-loader" class="relative block size-4" aria-hidden="true">
<span class="absolute left-[7px] top-[7px] size-0.5 bg-current" />
<For each={dots}>
{(position, index) => (
<span
class={`absolute size-1 bg-current ${position} animate-pulse`}
style={{ "animation-delay": `${index() * -180}ms` }}
/>
)}
</For>
</span>
)
}
function WorkspaceMoveAction(props: {
variant: "inline" | "panel"
eligible: boolean
sessionID: string
project: Project
directory: string
messageID?: string
dismissed: boolean
onDismiss: () => void
}) {
const language = useLanguage()
const inline = () => props.variant === "inline"
return (
<div
classList={{
"group/workspace-move relative shrink-0": true,
"ml-auto h-5 w-[167px]": inline(),
"-mt-2.5 h-[46px] w-full rounded-b-[6px] bg-v2-background-bg-layer-02 hover:bg-v2-background-bg-layer-03 transition-colors":
!inline(),
invisible: props.dismissed,
}}
>
<SessionWorkspaceMenu
eligible={props.eligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement={inline() ? "bottom-end" : "left-start"}
gutter={inline() ? 4 : -22}
contentClass={inline() ? undefined : "relative top-3.5"}
class={
inline()
? "flex h-5 w-full items-center gap-1.5 rounded-[4px] pr-6 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed"
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pr-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
}
>
<IconV2 name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
</SessionWorkspaceMenu>
<button
type="button"
class={`absolute flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none ${
inline()
? "right-0 top-1/2"
: "hover-reveal right-3 top-[calc(50%+5px)] group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100"
}`}
aria-label={language.t("common.dismiss")}
onClick={(event) => {
event.stopPropagation()
props.onDismiss()
}}
>
<IconV2 name="xmark-small" />
</button>
</div>
)
}
function SessionSummaryPanel(props: {
project: Project
directory: string
local: boolean
branch?: string
baseBranch?: string
diffs: { additions: number; deletions: number }[]
sessionID: string
moveEligible: boolean
messageID?: string
moveDismissed: boolean
onMoveDismiss: () => void
onReview: () => void
}) {
const language = useLanguage()
const location = () => (props.local ? language.t("session.new.workspace.local") : getFilename(props.directory))
const branch = () => props.branch ?? props.baseBranch
const row =
"flex h-7 w-full items-center gap-2 rounded-[4px] px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
return (
<div data-component="session-summary-panel" class="w-[280px]">
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
</div>
<SessionWorkspaceMenu
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
placement="left-start"
gutter={-22}
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
>
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate text-left">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</SessionWorkspaceMenu>
<div class={row}>
<IconV2 name="branch" class="shrink-0 text-v2-icon-icon-muted" />
<Show
when={props.branch}
fallback={
<span class="flex min-w-0 items-center gap-1.5">
<span>{language.t("session.summary.noBranch")}</span>
<Show when={props.baseBranch}>
{(base) => (
<>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">
{language.t("session.summary.basedOn", { branch: base() })}
</span>
</>
)}
</Show>
</span>
}
>
<span class="min-w-0 truncate">{branch()}</span>
</Show>
</div>
<button
type="button"
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none`}
onClick={props.onReview}
>
<IconV2 name="review" class="shrink-0 text-v2-icon-icon-muted" />
<Show when={props.diffs.length > 0} fallback={<span>{language.t("session.review.noChanges")}</span>}>
<span>{language.plural("ui.sessionTurn.diffs.changed", props.diffs.length)}</span>
<span class="text-v2-text-text-muted">·</span>
<DiffChangesV2 changes={props.diffs} />
</Show>
</button>
</div>
<Show when={props.local && props.diffs.length > 0 && props.moveEligible}>
<WorkspaceMoveAction
variant="panel"
eligible={props.moveEligible}
sessionID={props.sessionID}
project={props.project}
directory={props.directory}
messageID={props.messageID}
dismissed={props.moveDismissed}
onDismiss={props.onMoveDismiss}
/>
</Show>
</div>
)
}
function TimelineDiffView(props: { diff: SummaryDiff }) {
const fileComponent = useFileComponent()
const view = normalize(props.diff)
@ -251,6 +435,9 @@ export function MessageTimeline(props: {
centered: boolean
setContentRef: (el: HTMLDivElement) => void
userMessages: UserMessage[]
diffs: Accessor<{ additions: number; deletions: number }[]>
workspaceMoveEligible: boolean
onSummaryOpenChange: (open: boolean) => void
anchor: (id: string) => string
setRevealMessage?: (fn: (id: string) => void) => void
setScrollToEnd?: (fn: () => void) => void
@ -260,12 +447,14 @@ export function MessageTimeline(props: {
const navigate = useNavigate()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const sdk = useSDK()
const sync = useSync()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
const language = useLanguage()
const command = useCommand()
const { params, sessionKey } = useSessionKey()
const ownerSessionKey = sessionKey()
const cached = timelineCache.get(ownerSessionKey)
@ -298,6 +487,43 @@ export function MessageTimeline(props: {
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()))
const sessionDirectory = createMemo(() => info()?.directory ?? sdk().directory)
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
const [summaryOpen, setSummaryOpen] = createSignal(false)
const setSummary = (open: boolean) => {
setSummaryOpen(open)
props.onSummaryOpenChange(open)
}
const sessionDiffs = createMemo(props.diffs)
createEffect(
on(sessionID, () => {
setSummary(false)
setWorkspaceSuggestionDismissed(false)
}),
)
const turnPadding = () => "px-4 md:px-5"
const workspaceOperation = createMemo(() => {
const id = sessionID()
if (!id) return
return WorkspaceOperation.get(serverSDK().scope, id)
})
const lifecycleTitle = createMemo(() => {
const operation = workspaceOperation()
if (operation?.status === "pending") {
return {
kind: "pending" as const,
text: language.t(operation.type === "create" ? "workspace.lifecycle.creating" : "workspace.lifecycle.moving"),
}
}
if (operation?.type === "create" && titleValue()?.startsWith("New session"))
return { kind: "created" as const, text: language.t("workspace.lifecycle.created") }
if (titleValue()?.startsWith("New session"))
return { kind: "starting" as const, text: language.t("workspace.lifecycle.starting") }
return
})
const workspaceOperationPending = (sessionID: string) =>
WorkspaceOperation.get(serverSDK().scope, sessionID)?.status === "pending"
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
@ -329,7 +555,7 @@ export function MessageTimeline(props: {
if (value) return value
return language.t("command.session.new")
})
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const showHeader = createMemo(() => !!(titleValue() || parentID() || workspaceSession()))
const projection = createTimelineProjection({
messages: sessionMessages,
userMessages: () => props.userMessages,
@ -338,6 +564,19 @@ export function MessageTimeline(props: {
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
extensionRevision: workspaceOperation,
afterUser: (message) => {
const operation = workspaceOperation()
if (!operation) return []
if (operation.messageID !== message.id && (operation.messageID || message.id !== props.userMessages.at(-1)?.id))
return []
return [
new TimelineRow.WorkspaceLifecycle({
userMessageID: message.id,
notice: { type: "operation", operation },
}),
]
},
})
const activeMessageID = projection.activeMessageID
const assistantMessagesByParent = projection.assistantMessagesByParent
@ -833,6 +1072,7 @@ export function MessageTimeline(props: {
}
const archiveSession = async (sessionID: string) => {
if (workspaceOperationPending(sessionID)) return
const session = sync().session.get(sessionID)
if (!session) return
if ((await sdk().protocol) !== "v1") return
@ -863,6 +1103,7 @@ export function MessageTimeline(props: {
}
const deleteSession = async (sessionID: string) => {
if (workspaceOperationPending(sessionID)) return false
const session = sync().session.get(sessionID)
if (!session) return false
@ -956,7 +1197,7 @@ export function MessageTimeline(props: {
<ButtonV2 variant="ghost" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<ButtonV2 variant="danger" onClick={handleDelete}>
<ButtonV2 variant="danger" disabled={workspaceOperationPending(props.sessionID)} onClick={handleDelete}>
{language.t("session.delete.button")}
</ButtonV2>
</DialogFooter>
@ -975,7 +1216,12 @@ export function MessageTimeline(props: {
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
<Button
variant="primary"
size="large"
disabled={workspaceOperationPending(props.sessionID)}
onClick={handleDelete}
>
{language.t("session.delete.button")}
</Button>
</div>
@ -1128,7 +1374,7 @@ export function MessageTimeline(props: {
)
return (
<TimelineRowFrame row={commentStripRow}>
<div class="w-full px-4 md:px-5 pb-2">
<div class={`w-full pb-2 ${turnPadding()}`}>
<div class="ms-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
@ -1179,7 +1425,7 @@ export function MessageTimeline(props: {
<TimelineRowFrame row={userMessageRow}>
<Show when={message()}>
{(message) => (
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-message-content" aria-live="off">
<Message
message={message()}
@ -1195,11 +1441,55 @@ export function MessageTimeline(props: {
</TimelineRowFrame>
)
}
case "WorkspaceLifecycle": {
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
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 (
<TimelineRowFrame row={workspaceRow}>
<div class={`w-full ${turnPadding()}`} aria-live="polite">
<div class="flex h-7 items-center py-1 text-[13px] font-[440] leading-none tracking-[-0.04px]">
<Show
when={!pending()}
fallback={
<div class="flex items-center gap-1.5">
<TextShimmer text={status()} />
</div>
}
>
<div
classList={{
"flex items-center gap-1.5": true,
"text-v2-state-fg-danger": operation().status === "failed",
}}
>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-base"}>{status()}</span>
<Show when={operation().status !== "failed"}>
<span class="text-[11px] font-[530] italic text-v2-text-text-muted">·</span>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-accent" />
<Show when={directory()}>
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
</Show>
</Show>
</div>
</Show>
</div>
</div>
</TimelineRowFrame>
)
}
case "TurnDivider": {
const turnDividerRow = row as Accessor<TimelineRowByTag<"TurnDivider">>
return (
<TimelineRowFrame row={turnDividerRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div data-slot="session-turn-compaction">
<MessageDivider
label={language.t(
@ -1215,7 +1505,7 @@ export function MessageTimeline(props: {
const assistantPartRow = row as Accessor<TimelineRowByTag<"AssistantPart">>
return (
<TimelineRowFrame row={assistantPartRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<div
data-slot="session-turn-assistant-content"
aria-hidden={workingTurn(assistantPartRow().userMessageID)}
@ -1230,7 +1520,7 @@ export function MessageTimeline(props: {
const thinkingRow = row as Accessor<TimelineRowByTag<"Thinking">>
return (
<TimelineRowFrame row={thinkingRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineThinkingRow
reasoningHeading={thinkingRow().reasoningHeading}
showReasoningSummaries={settings.general.showReasoningSummaries()}
@ -1243,7 +1533,7 @@ export function MessageTimeline(props: {
const retryRow = row as Accessor<TimelineRowByTag<"Retry">>
return (
<TimelineRowFrame row={retryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<SessionRetry status={sessionStatus()} show={activeMessageID() === retryRow().userMessageID} />
</div>
</TimelineRowFrame>
@ -1251,10 +1541,35 @@ export function MessageTimeline(props: {
}
case "DiffSummary": {
const diffSummaryRow = row as Accessor<TimelineRowByTag<"DiffSummary">>
const canMove = () =>
settings.general.newLayoutDesigns() &&
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
!workspaceSession() &&
props.workspaceMoveEligible &&
sync().project?.vcs === "git" &&
sessionStatus().type === "idle"
return (
<TimelineRowFrame row={diffSummaryRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<TimelineDiffSummaryRow diffs={diffSummaryRow().diffs} />
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<TimelineDiffSummaryRow
diffs={diffSummaryRow().diffs}
action={
<Show when={canMove() && sync().project}>
{(project) => (
<WorkspaceMoveAction
variant="inline"
eligible={props.workspaceMoveEligible}
sessionID={sessionID()!}
project={project()}
directory={sessionDirectory()}
messageID={diffSummaryRow().userMessageID}
dismissed={workspaceSuggestionDismissed()}
onDismiss={() => setWorkspaceSuggestionDismissed(true)}
/>
)}
</Show>
}
/>
</div>
</TimelineRowFrame>
)
@ -1263,7 +1578,7 @@ export function MessageTimeline(props: {
const errorRow = row as Accessor<TimelineRowByTag<"Error">>
return (
<TimelineRowFrame row={errorRow}>
<div data-slot="session-turn-message-container" class="w-full px-4 md:px-5">
<div data-slot="session-turn-message-container" class={`w-full ${turnPadding()}`}>
<Card variant="error" class="error-card">
{errorRow().text}
</Card>
@ -1345,7 +1660,7 @@ export function MessageTimeline(props: {
}
return (
<div class="relative w-full h-full min-w-0">
<div class="relative w-full h-full min-w-0" data-workspace-session={workspaceSession() ? "" : undefined}>
<div
class="absolute left-1/2 -translate-x-1/2 z-[60] pointer-events-none transition-all duration-200 ease-out"
classList={{
@ -1440,6 +1755,39 @@ export function MessageTimeline(props: {
}}
>
<div class="flex items-center min-w-0 flex-1 w-full">
<Show when={settings.general.newLayoutDesigns()}>
<Show
when={workspaceOperation()?.status !== "pending"}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<WorkspaceLocationLoader />
</span>
}
>
<Show
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<IconV2 name="monitor" />
</span>
}
>
<TooltipV2
placement="bottom-start"
value={sessionDirectory()}
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<span
tabIndex={0}
aria-label={sessionDirectory()}
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
>
<IconV2 name="workspace-isolated" />
</span>
</TooltipV2>
</Show>
</Show>
</Show>
<Show when={parentID()}>
<button
type="button"
@ -1457,57 +1805,73 @@ export function MessageTimeline(props: {
/
</span>
</Show>
<Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
<Show
when={!lifecycleTitle()}
fallback={
<span
class="px-2 text-[13px] font-[530] leading-4 tracking-[-0.04px]"
classList={{ "text-v2-text-text-base": lifecycleTitle()?.kind === "created" }}
aria-live="polite"
>
<Show when={lifecycleTitle()?.kind !== "created"} fallback={lifecycleTitle()?.text}>
<TextShimmer text={lifecycleTitle()!.text} />
</Show>
</span>
}
>
<Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
data-slot="session-title-child"
classList={{
"truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-fit rounded-[6px] px-2 py-1 hover:bg-v2-overlay-simple-overlay-hover":
settings.general.newLayoutDesigns(),
"grow-1 min-w-0": !settings.general.newLayoutDesigns(),
}}
onClick={openTitleEditor}
>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
onClick={openTitleEditor}
>
{childTitle()}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]": !settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
classList={{
"block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base": true,
"w-full flex-1 grow-1 min-w-0 pl-1 -ml-1 rounded-[6px]":
!settings.general.newLayoutDesigns(),
"field-sizing-content self-start rounded-[6px] px-2 py-1 ":
settings.general.newLayoutDesigns(),
}}
style={{
"--inline-input-shadow": settings.general.newLayoutDesigns()
? "none"
: "var(--shadow-xs-border-select)",
}}
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={closeTitleEditor}
/>
</Show>
</Show>
</Show>
</div>
@ -1525,6 +1889,47 @@ export function MessageTimeline(props: {
placement="bottom"
buttonAppearance={settings.general.newLayoutDesigns() ? "v2" : "default"}
/>
<Show when={settings.general.newLayoutDesigns() && !parentID() && sync().project}>
{(project) => (
<KobaltePopover
open={summaryOpen()}
placement="bottom-end"
gutter={6}
onOpenChange={setSummary}
>
<KobaltePopover.Trigger
as={IconButtonV2}
icon={<IconV2 name="window-analytics" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label={language.t("session.summary.title")}
aria-expanded={summaryOpen()}
/>
<KobaltePopover.Portal>
<KobaltePopover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={sync().data.vcs?.branch}
baseBranch={serverSync().child(project().worktree)[0].vcs?.branch}
diffs={sessionDiffs()}
sessionID={id}
moveEligible={props.workspaceMoveEligible}
messageID={props.userMessages.at(-1)?.id}
moveDismissed={workspaceSuggestionDismissed()}
onMoveDismiss={() => setWorkspaceSuggestionDismissed(true)}
onReview={() => {
setSummary(false)
command.trigger("review.toggle")
}}
/>
</KobaltePopover.Content>
</KobaltePopover.Portal>
</KobaltePopover>
)}
</Show>
<Show when={!parentID()}>
<Show
when={settings.general.newLayoutDesigns()}
@ -1590,14 +1995,21 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Item onSelect={() => exportSession(id)}>
<DropdownMenu.Item
disabled={workspaceOperationPending(id)}
onSelect={() => exportSession(id)}
>
<DropdownMenu.ItemLabel>{language.t("common.export")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id)}>
<DropdownMenu.Item
disabled={workspaceOperationPending(id)}
onSelect={() => void archiveSession(id)}
>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
disabled={workspaceOperationPending(id)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
@ -1664,14 +2076,20 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<MenuV2.Item onSelect={() => exportSession(id)}>
<MenuV2.Item disabled={workspaceOperationPending(id)} onSelect={() => exportSession(id)}>
{language.t("common.export")}...
</MenuV2.Item>
<MenuV2.Item onSelect={() => void archiveSession(id)}>
<MenuV2.Item
disabled={workspaceOperationPending(id)}
onSelect={() => void archiveSession(id)}
>
{language.t("common.archive")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}>
<MenuV2.Item
disabled={workspaceOperationPending(id)}
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id} />)}
>
{language.t("common.delete")}...
</MenuV2.Item>
</MenuV2.Content>

View file

@ -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",
])
})

View file

@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
extensionRevision?: Accessor<unknown>
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),

View file

@ -3,6 +3,12 @@ import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
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))

View file

@ -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",
])
})

View file

@ -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<string, (typeof turns)[number]>()
@ -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))
}),
}
}

View file

@ -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}`
}
}

View file

@ -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(() => <x.DialogFork />),
@ -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({

View file

@ -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")
})
})

View file

@ -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<string, WorkspaceOperationState>()
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" })
},
}

View file

@ -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<T>(
request: (signal: AbortSignal) => Promise<T>,
message: string,
timeoutMs: number,
) {
const controller = new AbortController()
const timer = { id: undefined as ReturnType<typeof setTimeout> | undefined }
const timeout = new Promise<never>((_, 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)
})
}

View file

@ -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",
)
})

View file

@ -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<T extends WorkspaceProject & { id: string }>(projects: readonly T[]) {
return projects.flatMap((project) => workspaceDirectories(project).map((directory) => ({ directory, project })))
}
export function filterWorkspaceInventory<T extends { project: { id: string } }>(
workspaces: readonly T[],
project: string,
) {
if (project === "all") return [...workspaces]
return workspaces.filter((workspace) => workspace.project.id === project)
}
export function sessionsForWorkspace(sessions: readonly 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<T>(workspaces: readonly T[], remove: (workspace: T) => Promise<void>) {
return workspaces.reduce((previous, workspace) => previous.then(() => remove(workspace)), Promise.resolve())
}
export type WorkspaceDeleteInspection = "safe" | "active" | "linked" | "dirty"
export function inspectWorkspaceDeletion(input: {
workspace: string
activeDirectory?: string
sessions: readonly 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"
}

View file

@ -5,7 +5,14 @@ import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { I18nProvider } from "@opencode-ai/ui/context"
import { pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
import {
pluralCategory,
pluralKey,
type UiI18nParams,
type UiI18nPluralKey,
type UiTranslate,
type UiPluralCategory,
} from "@opencode-ai/ui/context/i18n"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js"
@ -58,20 +65,30 @@ function detectLocale() {
function UiI18nBridge(props: ParentProps) {
const locale = createMemo(() => detectLocale())
const zh = uiZh as Partial<Record<string, string>>
const t = (key: keyof typeof uiEn, params?: UiI18nParams) => {
const translate = (key: keyof typeof uiEn, params?: UiI18nParams) => {
const value = locale() === "zh" ? (zh[key] ?? uiEn[key]) : uiEn[key]
const text = value ?? String(key)
return resolveTemplate(text, params)
}
const t = translate as UiTranslate
const pluralForm = (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => {
const candidate = pluralKey(key, category)
const fallback = pluralKey(key, "other")
const value =
locale() === "zh"
? (zh[candidate] ?? zh[fallback] ?? uiEn[candidate] ?? uiEn[fallback])
: (uiEn[candidate] ?? uiEn[fallback])
return resolveTemplate(value ?? fallback, params)
}
const plural = (key: UiI18nPluralKey, count: number, params?: UiI18nParams) =>
t(pluralKey(key, pluralCategory(locale(), count)), { ...params, count })
pluralForm(key, pluralCategory(locale(), count), { ...params, count })
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
})
return <I18nProvider value={{ locale, t, plural }}>{props.children}</I18nProvider>
return <I18nProvider value={{ locale, t, plural, pluralForm }}>{props.children}</I18nProvider>
}
export default function App() {

View file

@ -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`.

View file

@ -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);

View file

@ -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 (

View file

@ -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<typeof pluralCategory>) =>
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(
() =>

View file

@ -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) => {

View file

@ -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`.

View file

@ -10,6 +10,7 @@ const icons = {
prompt: `<path d="M14.5841 12.0807H17.9193V2.91406H5.6276V6.2474M14.5859 6.2474H2.08594V15.4141H5.0026V17.4974L8.7526 15.4141H14.5859V6.2474Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
fork: `<path d="M2.91602 7.91406L2.91602 2.91406H7.91602M12.0827 2.91406H17.0827L17.0827 7.91406M9.99935 9.9974L9.99935 17.0807M9.99935 9.9974L3.33268 3.33073M9.99935 9.9974L16.666 3.33073" stroke="currentColor" stroke-linecap="square"/>`,
"workspace-isolated": `<g transform="translate(2 2)"><path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/></g>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,

View file

@ -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 string> = Key extends UiI18nPluralLookupKey ? never : Key
export type UiI18nParams = Record<string, string | number | boolean>
export type UiTranslate = <Key extends string>(key: UiTranslationKey<Key>, params?: UiI18nParams) => string
export type UiI18n = {
locale: Accessor<string>
layoutLocale?: Accessor<string>
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<string, Intl.PluralRules>()
@ -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<Record<UiI18nLocaleKey, string>>
const value = values[pluralKey(key, category)] ?? values[`${key}.other`] ?? `${key}.other`
return resolveTemplate(value, params)
},
}
const Context = createContext<UiI18n>(fallback)

View file

@ -1,4 +1,4 @@
export const dict: Record<string, string> = {
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<string, string> = {
"ui.question.multiHint": "Select all answers that apply",
"ui.question.singleHint": "Select one answer",
"ui.question.custom.placeholder": "Type your answer...",
}
} satisfies Record<string, string>
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<string, string> = source

View file

@ -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;

View file

@ -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: `<path d="M5.118 5.686V10.314M5.118 5.686C5.97 5.686 6.661 4.995 6.661 4.143C6.661 3.291 5.97 2.6 5.118 2.6C4.266 2.6 3.575 3.291 3.575 4.143C3.575 4.995 4.266 5.686 5.118 5.686ZM5.118 10.314C4.266 10.314 3.575 11.005 3.575 11.857C3.575 12.709 4.266 13.4 5.118 13.4C5.97 13.4 6.661 12.709 6.661 11.857M5.118 10.314C5.97 10.314 6.661 11.005 6.661 11.857M10.882 5.686C11.734 5.686 12.425 4.995 12.425 4.143C12.425 3.291 11.734 2.6 10.882 2.6C10.03 2.6 9.339 3.291 9.339 4.143C9.339 4.995 10.03 5.686 10.882 5.686ZM10.882 5.686V9.457C10.882 10.783 9.807 11.857 8.482 11.857H6.661" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>`,
},
"branch-out": {
viewBox: "0 0 16 16",
body: `<path d="M10.4225 3.35355L12.9024 5.83344L10.4225 8.31333" stroke="currentColor"/><path d="M1 12.2852H4.23042C4.89912 12.2852 5.52359 11.951 5.89452 11.3946L9.00783 6.72462C8.37877 6.16823 10.0032 5.83402 10.6719 5.83402H12.9024" stroke="currentColor"/><path d="M8.5 12.2852H14" stroke="currentColor" stroke-linejoin="round"/>`,
},
"grid-plus": {
viewBox: "0 0 16 16",
body: `<path d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/>`,
@ -121,6 +126,14 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M7 14.5H13M7 7.99512H10.0049M10.0049 7.99512H13M10.0049 7.99512V5M10.0049 7.99512V11M18 18V2L2 2L2 18H18Z" stroke="currentColor"/>`,
},
"window-analytics": {
viewBox: "0 0 16 16",
body: `<g transform="translate(1 2)"><path d="M7 4H11M7 8H11M0.5 0.5V11.5H13.5V0.5H0.5ZM3.5 3.5H4.5V4.5H3.5V3.5ZM3.5 7.5H4.5V8.5H3.5V7.5Z" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/></g>`,
},
trash: {
viewBox: "0 0 20 20",
body: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
},
"outline-sliders": {
viewBox: "0 0 16 16",
body: `<path d="M11.7779 4.66675H14.4446M11.7779 4.66675C11.7779 5.77132 10.8825 6.66675 9.77789 6.66675C8.67332 6.66675 7.77789 5.77132 7.77789 4.66675M11.7779 4.66675C11.7779 3.56218 10.8825 2.66675 9.77789 2.66675C8.67332 2.66675 7.77789 3.56218 7.77789 4.66675M1.55566 4.66675H7.77789M4.22233 11.3334H1.55566M4.22233 11.3334C4.22233 12.438 5.11776 13.3334 6.22233 13.3334C7.3269 13.3334 8.22233 12.438 8.22233 11.3334M4.22233 11.3334C4.22233 10.2288 5.11776 9.33341 6.22233 9.33341C7.3269 9.33341 8.22233 10.2288 8.22233 11.3334M14.4446 11.3334H8.22233" stroke="currentColor"/>`,

View file

@ -35,6 +35,7 @@ export function TooltipV2(props: TooltipV2Props) {
"value",
])
const controlled = () => local.forceOpen !== undefined
const inside = () => {
const active = document.activeElement
if (!ref || !active) return false
@ -119,9 +120,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