feat(app): add workspace flows to new layout

This commit is contained in:
LukeParkerDev 2026-07-25 12:18:15 +10:00
parent 5e2a6257b2
commit 974271d6b1
52 changed files with 2807 additions and 410 deletions

View file

@ -0,0 +1,419 @@
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 local, new, and existing workspaces from the ready-ish 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"]'))
const trigger = page.getByRole("button", { name: /^local$/i })
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("menuitem", { name: "Local repository" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "New workspace" })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "Workspace", exact: true })).toBeVisible()
await expect(page.getByRole("menuitem", { name: "View all" })).toHaveCount(0)
await page.getByRole("menuitem", { name: "New workspace" }).click()
await expect(page.getByRole("button", { name: /New workspace/ })).toBeVisible()
await expect(page.getByText("main", { exact: true })).toBeVisible()
await page.getByRole("button", { name: /New workspace/ }).click()
await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect(page.getByRole("button", { name: /feature/ })).toBeVisible()
})
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 },
}
let prompt: unknown
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
}
prompt = route.request().postDataJSON()
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")
for (const attempt of [1, 2, 3, 4, 5]) {
await transport.send({
directory: createdWorkspace,
payload: {
id: `evt_submit_ready_${attempt}`,
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await page.waitForTimeout(100)
if (prompt) break
}
await expect.poll(() => prompt).not.toBeUndefined()
await expect(lifecycle).toContainText("Workspace created")
})
test("shows neutral workspace identity and the ready-ish session summary panel", async ({ page }) => {
const sessionID = "ses_workspace_summary"
const messageID = "msg_workspace_summary"
const session = {
id: sessionID,
slug: "workspace-summary",
projectID: project.id,
directory: workspace,
title: "Workspace summary session",
version: "dev",
time: { created: 1, updated: 2 },
}
const vcsRequests: string[] = []
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/vcs/diff") vcsRequests.push(request.url())
})
await mockOpenCodeServer(page, {
protocol: "v2",
directory: workspace,
project,
provider,
sessions: [session],
pageMessages: () => ({ items: [userMessage(sessionID, messageID, "Implement workspace support", true)] }),
vcsDiff: [diff],
})
await init(page, { type: "session", sessionId: sessionID })
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
const timeline = page.locator("[data-workspace-session]")
await expect(timeline).toBeVisible()
await expect(timeline.locator(`[aria-label="${workspace}"]`)).toHaveAttribute("tabindex", "0")
await expect(timeline.locator('[data-slot="session-title-child"]')).toHaveClass(/text-v2-text-text-base/)
await expect(timeline.locator('[data-slot="user-message-text"]')).not.toHaveCSS(
"background-color",
"rgb(59, 92, 246)",
)
const title = page.locator("[data-session-title]")
await title.getByRole("button", { name: "Session details" }).click()
const panel = page.locator('[data-component="session-summary-panel"]')
await expect(panel).toBeVisible()
await expect(panel).toContainText("workspace-project")
await expect(panel).toContainText("feature")
await expect(panel).toContainText("1 Changed file")
await expect.poll(() => vcsRequests.length).toBeGreaterThan(0)
const request = new URL(vcsRequests.at(-1)!)
expect(request.searchParams.get("mode")).toBe("working")
expect(request.searchParams.get("location[directory]")).toBe(workspace)
})
test("moves a changed local session to an existing workspace with an end-of-turn divider", async ({ page }) => {
const sessionID = "ses_workspace_move_existing"
const messageID = "msg_workspace_move_existing"
const session = {
id: sessionID,
slug: "workspace-move-existing",
projectID: project.id,
directory: root,
title: "Move this session",
version: "dev",
time: { created: 1, updated: 2 },
}
let move: unknown
let releaseMove = () => {}
const moveGate = new Promise<void>((resolve) => {
releaseMove = resolve
})
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, "Move this work", true)] }),
vcsDiff: [diff],
})
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
}
move = route.request().postDataJSON()
await moveGate
session.directory = workspace
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()
const inlineMove = page
.locator('[data-component="session-turn-diffs-group"]')
.getByRole("button", { name: "Move to workspace" })
await expect(inlineMove).toBeVisible()
await inlineMove.click()
await page.getByRole("menuitem", { name: "Workspace", exact: true }).hover()
await page.getByRole("menuitem", { name: "feature" }).click()
await expect
.poll(() => move)
.toEqual({
sessionID,
destination: { directory: workspace },
moveChanges: true,
})
await page.locator("[data-session-title]").getByRole("button", { name: "More options" }).click()
await expect(page.getByRole("menuitem", { name: "Archive" })).toBeDisabled()
await expect(page.getByRole("menuitem", { name: /Delete/ })).toBeDisabled()
await page.keyboard.press("Escape")
releaseMove()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Workspace set")
})
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 },
}
let move: unknown
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
}
move = route.request().postDataJSON()
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()
await page
.locator('[data-component="session-summary-panel"]')
.getByRole("button", { name: "Move to workspace" })
.click()
await page.getByRole("menuitem", { name: "New workspace" }).click()
const lifecycle = page.locator('[data-timeline-row="WorkspaceLifecycle"]')
await expect(lifecycle).toContainText("Creating workspace")
for (const attempt of [1, 2, 3, 4, 5]) {
await transport.send({
directory: createdWorkspace,
payload: {
id: `evt_worktree_ready_${attempt}`,
type: "worktree.ready",
properties: { name: "quick-contrast-fix" },
},
})
await page.waitForTimeout(100)
if (move) break
}
await expect
.poll(() => move)
.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

@ -8,9 +8,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()
@ -135,11 +132,6 @@ export function TabsInfoPopup() {
</p>
<img src={homeImage} alt="" class="aspect-video w-full rounded-[6px] object-cover" />
<p>When you reopen the app, your tabs are still open.</p>
<p>
The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using
the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout
will become permanent in a few weeks.
</p>
</div>
</div>
</DrawerContent>

View file

@ -2,6 +2,9 @@ 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
@ -26,12 +29,17 @@ 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 sentShellDirectories: string[] = []
const syncedDirectories: 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 sentCommandDirectories: 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 } = {}
@ -40,6 +48,18 @@ let selected = "/repo/worktree-a"
let variant: string | undefined
let permissionServer = "server-a"
let createSessionGate: Promise<void> | undefined
let sessionFailure: Error | 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,16 +87,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)
const clientFor = (directory: string, track = true) => {
if (track) createdClients.push(directory)
return {
api: {
session: {
create: async (input: (typeof sessionCreateInputs)[number]) => {
await createSessionGate
if (sessionFailure) throw sessionFailure
const location = input.location?.directory ?? directory
createdSessions.push(location)
sessionCreateInputs.push(input)
@ -99,9 +124,11 @@ const clientFor = (directory: string) => {
},
command: async (input: unknown) => {
sentCommands.push(input)
sentCommandDirectories.push(directory)
},
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
sentShellDirectories.push(directory)
},
},
},
@ -110,7 +137,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 } }
},
},
}
}
@ -173,7 +209,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 +233,81 @@ 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, false).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)
optimisticSeeded.push(
!!value.directory &&
!!value.sessionID &&
!!storedSessions[value.directory]?.find((item) => item.id === value.sessionID)?.title,
)
},
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)
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 }>
}
},
]
},
}
},
}))
mock.module("@/context/platform", () => ({
@ -287,24 +335,204 @@ beforeEach(() => {
optimisticSeeded.length = 0
promoted.length = 0
promotedDrafts.length = 0
updatedDrafts.length = 0
sentCommands.length = 0
sentCommandDirectories.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
sentShellDirectories.length = 0
syncedDirectories.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
sessionFailure = undefined
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]
})
describe("prompt submit worktree selection", () => {
test("admits only one concurrent new-workspace submission", async () => {
selected = "create"
let release = () => {}
createWorktreeGate = new Promise<void>((resolve) => {
release = resolve
})
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
const first = submit.handleSubmit(event)
const duplicate = submit.handleSubmit(event)
expect(worktreeCreates).toBe(1)
release()
await Promise.all([first, duplicate])
expect(createdSessions).toEqual([worktreeDirectory])
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(1)
expect(createdSessions).toHaveLength(1)
expect(sentPrompts).toEqual([worktreeDirectory])
})
test("allows retry after new-workspace submission fails", async () => {
selected = "create"
worktreeFailure = new Error("create failed")
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(worktreeCreates).toBe(1)
expect(createdSessions).toEqual([])
worktreeFailure = undefined
await submit.handleSubmit(event)
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(worktreeCreates).toBe(2)
expect(createdSessions).toEqual([worktreeDirectory])
expect(sentPrompts).toEqual([worktreeDirectory])
})
test("aborts a hung new-workspace request and allows retry", async () => {
selected = "create"
worktreeHung = true
let resets = 0
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: () => resets++,
onSubmit: () => undefined,
worktreeRequestTimeoutMs: 1,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
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("retains workspace selection after session creation failure and clears it after retry", async () => {
sessionFailure = new Error("session create failed")
let resets = 0
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: () => {
resets++
selected = "main"
},
onSubmit: () => undefined,
})
const event = { preventDefault: () => undefined } as unknown as Event
await submit.handleSubmit(event)
expect(resets).toBe(0)
expect(selected).toBe("/repo/worktree-a")
sessionFailure = undefined
await submit.handleSubmit(event)
await settle()
expect(resets).toBe(1)
expect(selected).toBe("main")
})
test("reads the latest worktree accessor value per submit", async () => {
const submit = createPromptSubmit({
prompt,
@ -420,6 +648,54 @@ describe("prompt submit worktree selection", () => {
expect(enabledAutoAccept).toEqual([{ server: "server-a", sessionID: "session-1", directory: "/repo/worktree-a" }])
})
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
})
let submitted = 0
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: () => submitted++,
})
const result = submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
activeSDK = "server-b"
activeServerSync = "server-b"
activeDirectorySync = "server-b"
search.draftId = "draft-2"
release()
await result
await settle()
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("promotes drafts using the selected project's server", async () => {
search = { draftId: "draft-1" }
const submit = createPromptSubmit({
@ -448,7 +724,7 @@ describe("prompt submit worktree selection", () => {
})
test("includes the selected variant on optimistic prompts", async () => {
params = { id: "session-1" }
params.id = "session-1"
variant = "high"
const submit = createPromptSubmit({
@ -595,4 +871,154 @@ describe("prompt submit worktree selection", () => {
expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" })
expect(optimisticSeeded).toEqual([true])
})
test("waits for a new workspace before sending an initial shell", async () => {
selected = "create"
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,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
expect(sentShell).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")?.status).toBe("pending")
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("waits for a new workspace before sending an initial custom command", async () => {
selected = "create"
commands = [{ name: "deploy" }]
promptValue = [{ type: "text", content: "/deploy now", start: 0, end: 11 }]
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(sentCommands).toEqual([])
Worktree.ready(ServerScope.local, worktreeDirectory)
await settle()
expect(sentCommands).toHaveLength(1)
expect(sentCommands[0]).toMatchObject({
sessionID: "session-1",
command: "deploy",
arguments: "now",
agent: "agent",
model: { id: "model", providerID: "provider" },
files: [],
})
expect(sentCommandDirectories).toEqual([worktreeDirectory])
})
test("settles a pending workspace operation when the initial prompt is aborted", async () => {
selected = "create"
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(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",
message: "aborted",
})
})
test("settles a pending workspace operation when preparation fails", async () => {
selected = "create"
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,
})
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
Worktree.failed(ServerScope.local, worktreeDirectory, "bootstrap failed")
await settle()
expect(sentShell).toEqual([])
expect(WorkspaceOperation.get(ServerScope.local, "session-1")).toMatchObject({
status: "failed",
message: "bootstrap 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"
@ -26,9 +28,13 @@ import { Event } from "@opencode-ai/schema/event"
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
@ -42,6 +48,7 @@ export type FollowupDraft = {
type FollowupSendInput = {
api: DirectorySDK["api"]["session"]
scope: ServerScope
serverSync: ServerSync
sync: DirectorySync
draft: FollowupDraft
@ -55,6 +62,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 = () => {
@ -220,6 +229,7 @@ type PromptSubmitInput = {
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
worktreeRequestTimeoutMs?: number
}
export function createPromptSubmit(input: PromptSubmitInput) {
@ -235,7 +245,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
@ -248,18 +259,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, "aborted")
pending.delete(key)
return Promise.resolve()
}
@ -291,9 +307,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]
@ -325,6 +341,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()
@ -338,293 +355,375 @@ 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, worktree.message)
}
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, worktree.message)
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, result.message)
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: images.map((attachment) => ({
uri: attachment.dataUrl,
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((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: images.map((attachment) => ({
uri: attachment.dataUrl,
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"
@ -15,25 +14,33 @@ export function PromptWorkspaceSelector(props: {
onDone: () => void
}) {
const language = useLanguage()
let pending: string | undefined
const [search, setSearch] = createSignal("")
let pending: { type: "select"; value: string } | 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)
props.onDone()
}
const label = () => {
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
if (selected() === "main") return language.t("workspace.type.local")
if (props.value === "create") return language.t("workspace.new")
return getFilename(props.value)
}
@ -42,9 +49,9 @@ export function PromptWorkspaceSelector(props: {
<>
<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>
<MenuV2.Trigger 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 capitalize">{label()}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</MenuV2.Trigger>
<MenuV2.Portal>
@ -52,14 +59,14 @@ export function PromptWorkspaceSelector(props: {
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
<MenuV2.Item onSelect={() => select("main")}>
<IconV2 name="monitor" />
<Icon 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" />
<Icon name="workspace-new" />
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
@ -67,18 +74,40 @@ export function PromptWorkspaceSelector(props: {
</MenuV2.Item>
</MenuV2.Group>
<Show when={props.workspaces.length > 0}>
<MenuV2.Separator />
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<IconV2 name="workspace" />
{language.t("session.new.workspace.existing")}
<Icon name="workspace" />
{language.t("session.new.workspace.existing").replace(/…$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="max-w-[200px]">
<For each={props.workspaces}>
<MenuV2.SubContent class="w-[216px]">
<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
value={search()}
placeholder={language.t("common.search.placeholder")}
aria-label={language.t("common.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)}>
<IconV2 name="workspace-isolated" />
<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" />
@ -102,6 +131,7 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
const language = useLanguage()
const label = () => {
if (props.noGit) return language.t("session.new.git.none")
if (!props.branch) return undefined
return props.branch
}
@ -116,8 +146,8 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean }) {
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" />
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 px-1.5 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<Icon name={props.noGit ? "monitor" : "branch"} size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{value()}</span>
</div>
</TooltipV2>

View file

@ -0,0 +1,192 @@
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 { 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 {
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
children: JSX.Element
onOpenChange?: (open: boolean) => void
}) {
const language = useLanguage()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const operationPending = () => WorkspaceOperation.get(serverSDK().scope, props.sessionID)?.status === "pending"
const blocked = () =>
!props.eligible || operationPending() || serverSync().session.data.session_working(props.sessionID)
const workspaces = () =>
(props.project.sandboxes ?? []).filter((workspace) => pathKey(workspace) !== pathKey(props.directory))
const fail = (scope: ServerScope, sessionID: string, message: string) => {
setStore("selected", undefined)
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
WorkspaceOperation.fail(scope, sessionID, message)
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)
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)) {
fail(scope, sessionID, language.t("workspace.move.failed"))
return
}
await workspaceRequestWithTimeout(
(signal) =>
sdk.client.experimental.controlPlane.moveSession(
{
sessionID,
destination: { directory: destination },
moveChanges: true,
},
{ signal },
),
language.t("workspace.move.failed"),
WORKSPACE_PREPARATION_TIMEOUT_MS,
)
.then(async () => {
for (const attempt of Array.from({ length: 20 }, (_, index) => index)) {
const session = await workspaceRequestWithTimeout(
(signal) => sync.session.resolve(sessionID, { force: true, signal }),
language.t("workspace.move.failed"),
WORKSPACE_PLACEMENT_REFRESH_TIMEOUT_MS,
).catch(() => undefined)
if (session && pathKey(session.directory) === pathKey(destination)) {
WorkspaceOperation.complete(scope, sessionID, destination)
sync.reindexSession(sessionID, source)
return
}
if (WorkspaceOperation.get(scope, sessionID)?.status === "complete") return
await new Promise((resolve) => setTimeout(resolve, Math.min(250 + attempt * 50, 1_000)))
}
fail(scope, sessionID, language.t("workspace.move.failed"))
})
.catch((error) =>
fail(scope, sessionID, error instanceof Error ? error.message : language.t("common.requestFailed")),
)
}
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-[180px]">
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("workspace.move.title")}</MenuV2.GroupLabel>
<MenuV2.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
<Icon name="workspace-new" />
{language.t("workspace.new")}
</MenuV2.Item>
</MenuV2.Group>
<Show when={workspaces().length > 0}>
<MenuV2.Separator class="h-[0.5px]" />
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
<MenuV2.SubTrigger>
<Icon name="workspace" />
{language.t("session.new.workspace.existing").replace(/…$/, "")}
</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent class="w-[216px]">
<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.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

@ -208,6 +208,57 @@ describe("applyDirectoryEvent", () => {
expect(store.sessionTotal).toBe(2)
})
test("moves a root between loaded directory indexes exactly once", () => {
const info = { ...rootSession({ id: "session" }), directory: "/destination" }
const [source, setSource] = createStore(
baseState({ session: [{ ...info, directory: "/source" }], sessionTotal: 1 }),
)
const [destination, setDestination] = createStore(baseState({ session: [], sessionTotal: 0 }))
const created = { type: "session.created", properties: { info } }
const moved = {
type: "session.moved",
properties: { sessionID: info.id, location: { directory: info.directory } },
}
applyDirectoryEvent({
event: created,
store: destination,
setStore: setDestination,
push() {},
directory: "/destination",
loadLsp() {},
})
applyDirectoryEvent({
event: moved,
store: source,
setStore: setSource,
push() {},
directory: "/source",
loadLsp() {},
})
applyDirectoryEvent({
event: created,
store: destination,
setStore: setDestination,
push() {},
directory: "/destination",
loadLsp() {},
})
applyDirectoryEvent({
event: moved,
store: source,
setStore: setSource,
push() {},
directory: "/source",
loadLsp() {},
})
expect(source.session).toEqual([])
expect(source.sessionTotal).toBe(0)
expect(destination.session.map((session) => session.id)).toEqual([info.id])
expect(destination.sessionTotal).toBe(1)
})
test("cleans session caches when archived", () => {
const message = userMessage("msg_1", "ses_1")
const [store, setStore] = createStore(

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,99 @@
import { describe, expect, test } from "bun:test"
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
import {
adaptServerEvent,
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("resolves readiness in the server scope that received the event", () => {
const directory = "/repo/worktree-ready"
Worktree.pending(ServerScope.local, directory)
expect(
applyWorktreeEvent(
ServerScope.local,
{ directory, payload: { id: "ready", type: "worktree.ready", properties: { name: "ready" } } as Event },
"failed",
),
).toBe(true)
expect(Worktree.get(ServerScope.local, directory)).toEqual({ status: "ready" })
})
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("does not let duplicate readiness settle placement after creation", () => {
const directory = "/repo/worktree-placement"
Worktree.ready(ServerScope.local, directory)
WorkspaceOperation.start(ServerScope.local, "session-worktree-placement", "create", directory)
applyWorktreeEvent(
ServerScope.local,
{ directory, payload: { id: "ready-again", type: "worktree.ready", properties: { name: "ready" } } as Event },
"failed",
)
expect(WorkspaceOperation.get(ServerScope.local, "session-worktree-placement")?.status).toBe("pending")
})
})
test("moved events complete the matching workspace operation", () => {
WorkspaceOperation.start(ServerScope.local, "session-move", "move", "/workspace")
applyWorkspaceOperationEvent(ServerScope.local, {
directory: "/workspace",
payload: {
id: "moved",
type: "session.next.moved",
properties: {
timestamp: Date.now(),
sessionID: "session-move",
location: { directory: "/workspace" },
},
} as Event,
})
expect(WorkspaceOperation.get(ServerScope.local, "session-move")?.status).toBe("complete")
})
test("current moved events complete the matching workspace operation", () => {
WorkspaceOperation.start(ServerScope.local, "session-current-move", "move", "/workspace")
applyWorkspaceOperationEvent(ServerScope.local, {
directory: "/workspace",
payload: adaptServerEvent({
id: "moved-current",
created: Date.now(),
type: "session.moved",
data: {
sessionID: "session-current-move",
location: { directory: "/workspace" },
},
} as OpenCodeEvent),
})
expect(WorkspaceOperation.get(ServerScope.local, "session-current-move")?.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"
@ -138,6 +140,34 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) {
return output
}
export function applyWorktreeEvent(scope: ServerScope, event: QueuedServerEvent, fallback: string) {
if (event.payload.type === "worktree.ready") {
const pending = Worktree.get(scope, event.directory)?.status === "pending"
Worktree.ready(scope, event.directory)
if (pending) WorkspaceOperation.completeCreate(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)
WorkspaceOperation.failCreate(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 +216,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 +269,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
@ -433,6 +468,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

@ -220,6 +220,47 @@ 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("does not reuse a hung signal-scoped placement refresh", async () => {
let calls = 0
const client = {
session: {
get: async () => {
calls++
if (calls === 1) return new Promise<never>(() => {})
return { data: { ...session("child"), directory: "/destination" } }
},
},
} as unknown as OpencodeClient
const store = createServerSession(client)
store.remember({ ...session("child"), directory: "/source" })
const first = new AbortController()
void store.resolve("child", { force: true, signal: first.signal })
first.abort()
const result = await store.resolve("child", { force: true, signal: new AbortController().signal })
expect(calls).toBe(2)
expect(result.directory).toBe("/destination")
expect(store.get("child")?.directory).toBe("/destination")
})
test("loads session content through the server client", async () => {
const ctx = setup({ root: session("root") })
@ -340,6 +381,17 @@ describe("server session", () => {
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
})
test("indexes optimistic users for the current timeline projection", () => {
const ctx = setup({})
const message = userMessage("message-optimistic")
ctx.store.optimistic.add({ sessionID: "child", message, parts: [] })
expect(ctx.store.data.session_message.child.map((item) => item.id)).toEqual([message.id])
ctx.store.optimistic.remove({ sessionID: "child", messageID: message.id })
expect(ctx.store.data.session_message.child).toEqual([])
})
test("backfills an assistant-only initial page through its user root", async () => {
const user = userMessage("message-1")
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]

View file

@ -298,22 +298,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)
@ -1015,6 +1017,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" }))
@ -1338,6 +1361,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]))
setData(
"part_text_accum_delta",
@ -1371,6 +1395,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,5 +1,5 @@
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 {
McpApi,
McpListInput,
@ -7,13 +7,21 @@ import type {
SessionApi,
SessionInfo,
SessionListInput,
OpenCodeEvent,
} from "@opencode-ai/client/promise"
import { QueryClient } from "@tanstack/solid-query"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync"
import {
captureSessionMove,
loadActiveSessionsQuery,
loadMcpQuery,
loadMcpResourcesQuery,
seedActiveSessionStatuses,
} from "./server-sync"
import { ServerScope } from "@/utils/server-scope"
import { createServerSession } from "./server-session"
import { adaptServerEvent } from "./server-sdk"
describe("MCP queries", () => {
test("loads current servers for the requested location", async () => {
@ -100,6 +108,51 @@ 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
@ -172,6 +225,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,17 @@ 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 }
}
import { persisted } from "@/utils/persist"
import type { ServerApi } from "@/utils/server"
import type {
@ -523,19 +534,49 @@ 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 (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 (directory === "global") {
if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching)
@ -566,10 +607,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 })
@ -681,6 +718,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
updateConfig: updateConfigMutation.mutateAsync,
project: projectApi,
session,
reindexSession,
homeSessions,
mcp: {
toggle: async (directory: string, name: string) => {

View file

@ -894,6 +894,12 @@ export const dict = {
"session.delete.confirm": 'حذف الجلسة "{{name}}"؟',
"session.delete.button": "حذف الجلسة",
"workspace.new": "مساحة عمل جديدة",
"workspace.move.title": "نقل إلى مساحة العمل",
"workspace.move.failed": "تعذّر نقل الجلسة",
"workspace.lifecycle.creating": "جارٍ إنشاء مساحة العمل",
"workspace.lifecycle.created": "تم إنشاء مساحة العمل",
"workspace.lifecycle.moving": "جارٍ النقل إلى مساحة العمل",
"workspace.lifecycle.set": "تم تعيين مساحة العمل",
"workspace.type.local": "محلي",
"workspace.type.sandbox": "صندوق رمل",
"workspace.create.failed.title": "فشل إنشاء مساحة العمل",

View file

@ -908,6 +908,12 @@ export const dict = {
"session.delete.confirm": 'Excluir sessão "{{name}}"?',
"session.delete.button": "Excluir sessão",
"workspace.new": "Novo espaço de trabalho",
"workspace.move.title": "Mover para o espaço de trabalho",
"workspace.move.failed": "Não foi possível mover a sessão",
"workspace.lifecycle.creating": "Criando espaço de trabalho",
"workspace.lifecycle.created": "Espaço de trabalho criado",
"workspace.lifecycle.moving": "Movendo para o espaço de trabalho",
"workspace.lifecycle.set": "Espaço de trabalho definido",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Falha ao criar espaço de trabalho",

View file

@ -984,6 +984,12 @@ export const dict = {
"session.delete.button": "Izbriši sesiju",
"workspace.new": "Novi radni prostor",
"workspace.move.title": "Premjesti u radni prostor",
"workspace.move.failed": "Premještanje sesije nije uspjelo",
"workspace.lifecycle.creating": "Kreiranje radnog prostora",
"workspace.lifecycle.created": "Radni prostor je kreiran",
"workspace.lifecycle.moving": "Premještanje u radni prostor",
"workspace.lifecycle.set": "Radni prostor je postavljen",
"workspace.type.local": "lokalno",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Neuspješno kreiranje radnog prostora",

View file

@ -976,6 +976,12 @@ export const dict = {
"session.delete.button": "Slet session",
"workspace.new": "Nyt arbejdsområde",
"workspace.move.title": "Flyt til arbejdsområde",
"workspace.move.failed": "Sessionen kunne ikke flyttes",
"workspace.lifecycle.creating": "Opretter arbejdsområde",
"workspace.lifecycle.created": "Arbejdsområde oprettet",
"workspace.lifecycle.moving": "Flytter til arbejdsområde",
"workspace.lifecycle.set": "Arbejdsområde angivet",
"workspace.type.local": "lokal",
"workspace.type.sandbox": "sandkasse",
"workspace.create.failed.title": "Kunne ikke oprette arbejdsområde",

View file

@ -920,6 +920,12 @@ export const dict = {
"session.delete.confirm": 'Sitzung "{{name}}" löschen?',
"session.delete.button": "Sitzung löschen",
"workspace.new": "Neuer Arbeitsbereich",
"workspace.move.title": "In Arbeitsbereich verschieben",
"workspace.move.failed": "Sitzung konnte nicht verschoben werden",
"workspace.lifecycle.creating": "Arbeitsbereich wird erstellt",
"workspace.lifecycle.created": "Arbeitsbereich erstellt",
"workspace.lifecycle.moving": "Wird in den Arbeitsbereich verschoben",
"workspace.lifecycle.set": "Arbeitsbereich festgelegt",
"workspace.type.local": "lokal",
"workspace.type.sandbox": "Sandbox",
"workspace.create.failed.title": "Arbeitsbereich konnte nicht erstellt werden",

View file

@ -1075,6 +1075,12 @@ export const dict = {
"session.delete.button": "Delete session",
"workspace.new": "New workspace",
"workspace.move.title": "Move to workspace",
"workspace.move.failed": "Failed to move session",
"workspace.lifecycle.creating": "Creating workspace",
"workspace.lifecycle.created": "Workspace created",
"workspace.lifecycle.moving": "Moving to workspace",
"workspace.lifecycle.set": "Workspace set",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Failed to create workspace",

View file

@ -992,6 +992,12 @@ export const dict = {
"session.delete.button": "Eliminar sesión",
"workspace.new": "Nuevo espacio de trabajo",
"workspace.move.title": "Mover al espacio de trabajo",
"workspace.move.failed": "No se pudo mover la sesión",
"workspace.lifecycle.creating": "Creando espacio de trabajo",
"workspace.lifecycle.created": "Espacio de trabajo creado",
"workspace.lifecycle.moving": "Moviendo al espacio de trabajo",
"workspace.lifecycle.set": "Espacio de trabajo establecido",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Fallo al crear espacio de trabajo",

View file

@ -919,6 +919,12 @@ export const dict = {
"session.delete.confirm": 'Supprimer la session "{{name}}" ?',
"session.delete.button": "Supprimer la session",
"workspace.new": "Nouvel espace de travail",
"workspace.move.title": "Déplacer vers lespace de travail",
"workspace.move.failed": "Impossible de déplacer la session",
"workspace.lifecycle.creating": "Création de lespace de travail",
"workspace.lifecycle.created": "Espace de travail créé",
"workspace.lifecycle.moving": "Déplacement vers lespace de travail",
"workspace.lifecycle.set": "Espace de travail défini",
"workspace.type.local": "local",
"workspace.type.sandbox": "bac à sable",
"workspace.create.failed.title": "Échec de la création de l'espace de travail",

View file

@ -901,6 +901,12 @@ export const dict = {
"session.delete.confirm": 'セッション "{{name}}" を削除しますか?',
"session.delete.button": "セッションを削除",
"workspace.new": "新しいワークスペース",
"workspace.move.title": "ワークスペースに移動",
"workspace.move.failed": "セッションを移動できませんでした",
"workspace.lifecycle.creating": "ワークスペースを作成中",
"workspace.lifecycle.created": "ワークスペースを作成しました",
"workspace.lifecycle.moving": "ワークスペースに移動中",
"workspace.lifecycle.set": "ワークスペースを設定しました",
"workspace.type.local": "ローカル",
"workspace.type.sandbox": "サンドボックス",
"workspace.create.failed.title": "ワークスペースの作成に失敗しました",

View file

@ -742,6 +742,12 @@ export const dict = {
"session.delete.confirm": '"{{name}}" 세션을 삭제하시겠습니까?',
"session.delete.button": "세션 삭제",
"workspace.new": "새 작업 공간",
"workspace.move.title": "작업 공간으로 이동",
"workspace.move.failed": "세션을 이동하지 못했습니다",
"workspace.lifecycle.creating": "작업 공간 생성 중",
"workspace.lifecycle.created": "작업 공간을 만들었습니다",
"workspace.lifecycle.moving": "작업 공간으로 이동 중",
"workspace.lifecycle.set": "작업 공간을 설정했습니다",
"workspace.type.local": "로컬",
"workspace.type.sandbox": "샌드박스",
"workspace.create.failed.title": "작업 공간 생성 실패",

View file

@ -829,6 +829,12 @@ export const dict = {
"session.delete.button": "Slett sesjon",
"workspace.new": "Nytt arbeidsområde",
"workspace.move.title": "Flytt til arbeidsområde",
"workspace.move.failed": "Kunne ikke flytte økten",
"workspace.lifecycle.creating": "Oppretter arbeidsområde",
"workspace.lifecycle.created": "Arbeidsområdet er opprettet",
"workspace.lifecycle.moving": "Flytter til arbeidsområde",
"workspace.lifecycle.set": "Arbeidsområdet er angitt",
"workspace.type.local": "lokal",
"workspace.type.sandbox": "sandkasse",
"workspace.create.failed.title": "Kunne ikke opprette arbeidsområde",

View file

@ -907,6 +907,12 @@ export const dict = {
"session.delete.confirm": 'Usunąć sesję "{{name}}"?',
"session.delete.button": "Usuń sesję",
"workspace.new": "Nowa przestrzeń robocza",
"workspace.move.title": "Przenieś do obszaru roboczego",
"workspace.move.failed": "Nie udało się przenieść sesji",
"workspace.lifecycle.creating": "Tworzenie obszaru roboczego",
"workspace.lifecycle.created": "Utworzono obszar roboczy",
"workspace.lifecycle.moving": "Przenoszenie do obszaru roboczego",
"workspace.lifecycle.set": "Ustawiono obszar roboczy",
"workspace.type.local": "lokalna",
"workspace.type.sandbox": "piaskownica",
"workspace.create.failed.title": "Nie udało się utworzyć przestrzeni roboczej",

View file

@ -987,6 +987,12 @@ export const dict = {
"session.delete.button": "Удалить сессию",
"workspace.new": "Новое рабочее пространство",
"workspace.move.title": "Переместить в рабочую область",
"workspace.move.failed": "Не удалось переместить сеанс",
"workspace.lifecycle.creating": "Создание рабочей области",
"workspace.lifecycle.created": "Рабочая область создана",
"workspace.lifecycle.moving": "Перемещение в рабочую область",
"workspace.lifecycle.set": "Рабочая область задана",
"workspace.type.local": "локальное",
"workspace.type.sandbox": "песочница",
"workspace.create.failed.title": "Не удалось создать рабочее пространство",

View file

@ -971,6 +971,12 @@ export const dict = {
"session.delete.button": "ลบเซสชัน",
"workspace.new": "พื้นที่ทำงานใหม่",
"workspace.move.title": "ย้ายไปยังพื้นที่ทำงาน",
"workspace.move.failed": "ย้ายเซสชันไม่สำเร็จ",
"workspace.lifecycle.creating": "กำลังสร้างพื้นที่ทำงาน",
"workspace.lifecycle.created": "สร้างพื้นที่ทำงานแล้ว",
"workspace.lifecycle.moving": "กำลังย้ายไปยังพื้นที่ทำงาน",
"workspace.lifecycle.set": "ตั้งค่าพื้นที่ทำงานแล้ว",
"workspace.type.local": "ในเครื่อง",
"workspace.type.sandbox": "แซนด์บ็อกซ์",
"workspace.create.failed.title": "ไม่สามารถสร้างพื้นที่ทำงาน",

View file

@ -991,6 +991,12 @@ export const dict = {
"session.delete.button": "Oturumu sil",
"workspace.new": "Yeni çalışma alanı",
"workspace.move.title": "Çalışma alanına taşı",
"workspace.move.failed": "Oturum taşınamadı",
"workspace.lifecycle.creating": "Çalışma alanı oluşturuluyor",
"workspace.lifecycle.created": "Çalışma alanı oluşturuldu",
"workspace.lifecycle.moving": "Çalışma alanına taşınıyor",
"workspace.lifecycle.set": "Çalışma alanı ayarlandı",
"workspace.type.local": "yerel",
"workspace.type.sandbox": "sandbox",
"workspace.create.failed.title": "Çalışma alanı oluşturulamadı",

View file

@ -1078,6 +1078,12 @@ export const dict = {
"session.delete.button": "Видалити сесію",
"workspace.new": "Нова робоча область",
"workspace.move.title": "Перемістити до робочого простору",
"workspace.move.failed": "Не вдалося перемістити сеанс",
"workspace.lifecycle.creating": "Створення робочого простору",
"workspace.lifecycle.created": "Робочий простір створено",
"workspace.lifecycle.moving": "Переміщення до робочого простору",
"workspace.lifecycle.set": "Робочий простір установлено",
"workspace.type.local": "локальна",
"workspace.type.sandbox": "пісочниця",
"workspace.create.failed.title": "Не вдалося створити робочу область",

View file

@ -964,6 +964,12 @@ export const dict = {
"session.delete.button": "删除会话",
"workspace.new": "新建工作区",
"workspace.move.title": "移动到工作区",
"workspace.move.failed": "无法移动会话",
"workspace.lifecycle.creating": "正在创建工作区",
"workspace.lifecycle.created": "工作区已创建",
"workspace.lifecycle.moving": "正在移动到工作区",
"workspace.lifecycle.set": "工作区已设置",
"workspace.type.local": "本地",
"workspace.type.sandbox": "沙盒",
"workspace.create.failed.title": "创建工作区失败",

View file

@ -960,6 +960,12 @@ export const dict = {
"session.delete.button": "刪除工作階段",
"workspace.new": "新增工作區",
"workspace.move.title": "移動到工作區",
"workspace.move.failed": "無法移動工作階段",
"workspace.lifecycle.creating": "正在建立工作區",
"workspace.lifecycle.created": "工作區已建立",
"workspace.lifecycle.moving": "正在移動到工作區",
"workspace.lifecycle.set": "工作區已設定",
"workspace.type.local": "本地",
"workspace.type.sandbox": "沙盒",
"workspace.create.failed.title": "建立工作區失敗",

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

@ -20,6 +20,7 @@ import { displayName, errorMessage, projectForSession } from "@/pages/layout/hel
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"
@ -210,6 +211,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)
await archiveHomeSession({
server: ServerConnection.key(conn),

View file

@ -62,6 +62,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,
@ -872,6 +873,7 @@ export default function LegacyLayout(props: ParentProps) {
}
async function archiveSession(session: Session) {
if (WorkspaceOperation.get(serverSDK().scope, session.id)?.status === "pending") return
const [store, setStore] = serverSync().child(session.directory)
const sessions = store.session ?? []
const index = sessions.findIndex((s) => s.id === session.id)

View file

@ -1,4 +1,4 @@
import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js"
import { Show, createEffect, createMemo, createResource, createSignal, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useSearchParams } from "@solidjs/router"
@ -34,10 +34,10 @@ import { Persist, persisted } from "@/utils/persist"
import createPresence from "solid-presence"
import { useLocal } from "@/context/local"
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
import { useTabs, type DraftTab } from "@/context/tabs"
import { isWorkspaceSelection } from "@/utils/workspace"
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000
const providerTipExitDuration = 250
/**
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@ -63,6 +63,7 @@ export default function NewSessionPage() {
useSettingsCommand()
const route = useSessionKey()
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
const tabs = useTabs()
const local = useLocal()
const model = createPromptModelSelection({ agent: local.agent.current })
@ -76,13 +77,26 @@ export default function NewSessionPage() {
})
const projectControls = createPromptProjectControls()
const [store, setStore] = createStore<{ worktree?: string }>({})
const rightMount = useTitlebarRightMount()
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
const draft = createMemo(() =>
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === searchParams.draftId),
)
const setWorktree = (worktree: string | undefined) => {
const id = searchParams.draftId
if (!id) return
tabs.updateDraft(id, { worktree })
}
const showWorkspaceBar = createMemo(() => sync().project?.vcs === "git")
const selectedWorktree = createMemo(() => {
const project = sync().project
const worktree = draft()?.worktree
if (!project || !worktree) return undefined
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
const newSessionWorktree = createMemo(() => {
if (!showWorkspaceBar()) return "main"
if (store.worktree) return store.worktree
const worktree = selectedWorktree()
if (worktree) return worktree
const project = sync().project
if (project && sdk().directory !== project.worktree) return sdk().directory
return "main"
@ -92,8 +106,12 @@ export default function NewSessionPage() {
const selectedBranch = createMemo(() => {
const worktree = newSessionWorktree()
if (worktree === "main" || worktree === "create") return localBranch()
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
return serverSync().child(worktree)[0].vcs?.branch
})
const selectWorktree = (worktree: string) => {
const project = sync().project
setWorktree(worktree === "main" && project?.worktree !== sdk().directory ? project?.worktree : worktree)
}
const promptInputV2Controller = usePromptInputV2Controller({
get controls() {
return inputController()
@ -101,8 +119,8 @@ export default function NewSessionPage() {
get newSessionWorktree() {
return newSessionWorktree()
},
onNewSessionWorktreeReset: () => setStore("worktree", undefined),
onSubmit: () => comments.clear(),
onNewSessionWorktreeReset: () => setWorktree(undefined),
onSubmit: comments.clear,
})
const projectController = createPromptProjectController({
controls: projectControls,
@ -185,14 +203,7 @@ export default function NewSessionPage() {
projectRoot={projectRoot()}
workspaces={sync().project?.sandboxes ?? []}
branch={selectedBranch()}
onChange={(value) =>
setStore(
"worktree",
value === "main" && sync().project?.worktree !== sdk().directory
? sync().project?.worktree
: value,
)
}
onChange={selectWorktree}
onDone={promptInputV2Controller.restoreFocus}
/>
</Show>

View file

@ -99,6 +99,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"
@ -599,6 +600,7 @@ export default function Page() {
const [store, setStore] = createStore({
...sessionViewState(),
newSessionWorktree: "main",
sessionDetailsOpen: false,
deferRender: false,
})
@ -698,6 +700,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")
@ -1700,6 +1715,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
@ -1713,8 +1730,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
@ -1724,6 +1753,7 @@ export default function Page() {
const ok = await sendFollowupDraft({
api: sdk().api.session,
scope: serverSDK().scope,
sync: sync(),
serverSync: serverSync(),
draft: item,
@ -1786,6 +1816,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()
@ -1825,6 +1856,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
@ -1847,6 +1879,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()
@ -1874,7 +1907,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 }) => {
@ -1932,6 +1968,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)
})
@ -2102,6 +2139,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

View file

@ -87,7 +87,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

@ -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"
@ -63,6 +67,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"
@ -76,6 +81,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[] = []
@ -141,7 +152,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({
@ -172,6 +183,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
@ -226,6 +238,166 @@ 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-6 w-[155px]": inline(),
"h-[46px] w-full bg-v2-background-bg-layer-02 rounded-b-[6px]": !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 : -2}
class={
inline()
? "flex h-6 w-full items-center gap-1.5 rounded-[4px] px-1.5 pr-7 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-1.5 rounded-b-[6px] px-3 pr-9 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base 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="workspace" 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="hover-reveal absolute right-1 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-v2-icon-icon-muted group-hover/workspace-move:opacity-100 group-focus-within/workspace-move:opacity-100 hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
aria-label={language.t("common.dismiss")}
onClick={(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] overflow-hidden rounded-[6px] bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)]"
>
<div class="flex flex-col gap-1 px-0.5 py-1.5">
<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">{displayName(props.project)}</span>
</div>
<div class={row}>
<IconV2 name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 flex-1 truncate">{location()}</span>
<IconV2 name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</div>
<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>No branch</span>
<Show when={props.baseBranch}>
<span class="text-v2-text-text-muted">·</span>
<span class="truncate text-v2-text-text-faint">Based on {props.baseBranch}</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.t(
props.diffs.length === 1 ? "ui.sessionTurn.diffs.changed.one" : "ui.sessionTurn.diffs.changed.other",
{ count: String(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)
@ -253,6 +425,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
@ -262,12 +437,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 +475,29 @@ 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 = () => (settings.general.newLayoutDesigns() ? "px-3" : "px-4 md:px-5")
const workspaceOperation = createMemo(() => {
const id = sessionID()
if (!id) return
return WorkspaceOperation.get(serverSDK().scope, id)
})
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 +529,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 +538,19 @@ export function MessageTimeline(props: {
status: sessionStatus,
showReasoningSummaries: settings.general.showReasoningSummaries,
inlineComments: settings.general.newLayoutDesigns,
extensionRevision: workspaceOperation,
afterTurn: (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
@ -810,6 +1023,7 @@ export function MessageTimeline(props: {
}
const archiveSession = async (sessionID: string) => {
if (workspaceOperationPending(sessionID)) return
const session = sync().session.get(sessionID)
if (!session) return
@ -839,6 +1053,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
@ -932,7 +1147,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>
@ -951,7 +1166,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>
@ -1081,7 +1301,8 @@ export function MessageTimeline(props: {
data-timeline-row={input.row()._tag}
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
"md:max-w-[720px]": props.centered && settings.general.newLayoutDesigns(),
"md:max-w-200 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
"md:mx-auto": props.centered,
"pt-3": previousAssistantPart(),
}}
@ -1104,7 +1325,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="ml-auto max-w-[82%] overflow-x-auto no-scrollbar">
<div class="flex w-max min-w-full justify-end gap-2">
<Index each={comments()}>
@ -1155,7 +1376,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()}
@ -1171,11 +1392,62 @@ export function MessageTimeline(props: {
</TimelineRowFrame>
)
}
case "WorkspaceLifecycle": {
const workspaceRow = row as Accessor<TimelineRowByTag<"WorkspaceLifecycle">>
const operation = createMemo(() => {
const rowOperation = workspaceRow().notice.operation
const current = WorkspaceOperation.get(serverSDK().scope, sessionID()!)
if (current?.messageID === workspaceRow().userMessageID) return current
if (
!rowOperation.messageID &&
current?.directory === rowOperation.directory &&
current.type === rowOperation.type
)
return current
return rowOperation
})
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-11 w-full items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
<Show when={!pending()} fallback={<TextShimmer text={status()} />}>
<span
classList={{
"flex min-w-0 items-center gap-1.5": true,
"text-v2-state-fg-danger": operation().status === "failed",
}}
>
<Show when={operation().status !== "failed"}>
<IconV2 name="workspace-isolated" class="shrink-0 text-v2-icon-icon-muted" />
</Show>
<Show when={directory()}>
<span class="max-w-[240px] truncate text-v2-text-text-base">{directory()}</span>
</Show>
<span class={operation().status === "failed" ? "" : "text-v2-text-text-faint lowercase"}>
{status()}
</span>
</span>
</Show>
<span class="h-px min-w-0 flex-1 bg-v2-border-border-muted" />
</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(
@ -1191,7 +1463,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)}
@ -1206,7 +1478,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()}
@ -1219,7 +1491,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>
@ -1227,10 +1499,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>
)
@ -1239,7 +1536,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>
@ -1321,7 +1618,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={{
@ -1416,6 +1713,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-muted"
>
<IconV2 name="workspace-isolated" />
</span>
</TooltipV2>
</Show>
</Show>
</Show>
<Show when={parentID()}>
<button
type="button"
@ -1501,6 +1831,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="review" />}
variant="ghost-muted"
size="large"
state={summaryOpen() ? "pressed" : undefined}
aria-label="Session details"
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()}
@ -1566,11 +1937,15 @@ export function MessageTimeline(props: {
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<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>
@ -1637,11 +2012,17 @@ export function MessageTimeline(props: {
{language.t("session.share.action.share")}...
</MenuV2.Item>
</Show>
<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 { appendTurnExtensions, 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("appends turn extensions after projected turn rows", () => {
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(appendTurnExtensions(rows, [lifecycle]).map((row) => row._tag)).toEqual([
"UserMessage",
"DiffSummary",
"WorkspaceLifecycle",
])
})

View file

@ -14,6 +14,8 @@ export function createTimelineProjection(input: {
status: Accessor<SessionStatus>
showReasoningSummaries: Accessor<boolean>
inlineComments: Accessor<boolean>
extensionRevision?: Accessor<unknown>
afterTurn?: (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.afterTurn,
)
})
const activeMessageID = createMemo(() => projection().activeMessageID)
const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) =>
reuseTimelineRows(previous, projection().rows),

View file

@ -3,6 +3,11 @@ import { TimelineRow } from "./timeline-row"
type ContextRow = Extract<TimelineRow.TimelineRow, { _tag: "AssistantPart" }>
type PriorContext = { index: number; row: ContextRow }
export function appendTurnExtensions(rows: TimelineRow.TimelineRow[], extensions: TimelineRow.TimelineRow[]) {
rows.push(...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

@ -167,4 +167,36 @@ describe("current session timeline rows", () => {
"thinking:msg_2",
])
})
test("appends lifecycle extensions after current turns", () => {
const source = [
{ id: "msg_user", type: "user", text: "question", time: { created: 1 } },
] satisfies SessionMessageInfo[]
const normalized = normalizeSessionMessages("ses_1", source)
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
const result = Timeline.constructSessionMessageRows(
source,
(messageID) => messages.get(messageID),
(messageID) => normalized.parts.get(messageID) ?? [],
true,
"idle",
true,
normalized.messages.filter((message) => message.role === "user"),
(message) => [
new TimelineRow.WorkspaceLifecycle({
userMessageID: message.id,
notice: {
type: "operation",
operation: { type: "move", status: "complete", directory: "/workspace", messageID: message.id },
},
}),
],
)
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"workspace-lifecycle:msg_user:operation",
])
})
})

View file

@ -4,6 +4,7 @@ import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai
import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part"
import { TimelineRow, type SummaryDiff } from "./timeline-row"
import { uniqueSummaryDiffs } from "./summary-diffs"
import { appendTurnExtensions } from "./row-reconciliation"
export { TimelineRow, type SummaryDiff } from "./timeline-row"
@ -27,6 +28,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 }
}
@ -40,6 +45,7 @@ export namespace Timeline {
status: SessionStatus["type"],
inlineComments: boolean,
projectedUserMessages: UserMessage[],
afterTurn?: (message: UserMessage) => TimelineRow.TimelineRow[],
) {
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
const turnByUserID = new Map<string, (typeof turns)[number]>()
@ -82,8 +88,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,
@ -92,8 +98,10 @@ export namespace Timeline {
status,
turn.user.id === activeMessageID,
inlineComments,
),
),
)
if (!afterTurn) return rows
return appendTurnExtensions(rows, afterTurn(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

@ -19,6 +19,7 @@ import { 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
@ -73,6 +74,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
@ -305,6 +308,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
@ -333,6 +337,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()
@ -365,6 +370,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) {
@ -382,6 +388,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 />),
@ -431,7 +440,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({
@ -439,7 +448,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({
@ -447,7 +456,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({
@ -455,7 +464,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,
}),
]

View file

@ -0,0 +1,39 @@
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("reacts to operation completion", () => {
WorkspaceOperation.start(ServerScope.local, "session", "move", "/workspace")
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("pending")
WorkspaceOperation.complete(ServerScope.local, "session")
expect(WorkspaceOperation.get(ServerScope.local, "session")?.status).toBe("complete")
})
test("ignores completion for a different destination", () => {
WorkspaceOperation.start(ServerScope.local, "destination", "move", "/workspace/expected")
WorkspaceOperation.complete(ServerScope.local, "destination", "/workspace/other")
expect(WorkspaceOperation.get(ServerScope.local, "destination")?.status).toBe("pending")
})
test("does not downgrade a completed move after cleanup fails", () => {
WorkspaceOperation.start(ServerScope.local, "cleanup", "move", "/workspace")
WorkspaceOperation.complete(ServerScope.local, "cleanup")
WorkspaceOperation.fail(ServerScope.local, "cleanup", "source cleanup failed")
expect(WorkspaceOperation.get(ServerScope.local, "cleanup")?.status).toBe("complete")
})
test("settles a pending create from its worktree directory", () => {
WorkspaceOperation.start(ServerScope.local, "create", "create", "/workspace/create")
WorkspaceOperation.completeCreate(ServerScope.local, "/workspace/create")
expect(WorkspaceOperation.get(ServerScope.local, "create")?.status).toBe("complete")
})
})

View file

@ -0,0 +1,74 @@
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
message?: 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)
}
const settleCreate = (scope: ServerScope, directory: string, status: "complete" | "failed", message?: string) => {
const prefix = ScopedKey.prefix(scope)
const target = pathKey(directory)
const matches = [...state].filter(
([id, item]) =>
id.startsWith(prefix) &&
item.type === "create" &&
item.status === "pending" &&
pathKey(item.directory) === target,
)
if (matches.length === 0) return
matches.forEach(([id, item]) => state.set(id, { ...item, status, message }))
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" })
},
completeCreate(scope: ServerScope, directory: string) {
settleCreate(scope, directory, "complete")
},
failCreate(scope: ServerScope, directory: string, message: string) {
settleCreate(scope, directory, "failed", message)
},
fail(scope: ServerScope, sessionID: string, message: string) {
const current = state.get(key(scope, sessionID))
if (!current || current.status === "complete") return
write(scope, sessionID, { ...current, status: "failed", message })
},
}

View file

@ -0,0 +1,36 @@
import { expect, test } from "bun:test"
import { workspaceRequestWithTimeout } from "./workspace-request"
test("workspace requests abort at the bounded timeout", async () => {
let aborted = false
const request = workspaceRequestWithTimeout(
(signal) =>
new Promise<never>((_, reject) => {
signal.addEventListener(
"abort",
() => {
aborted = true
reject(signal.reason)
},
{ once: true },
)
}),
"Workspace request failed",
1,
)
await expect(request).rejects.toThrow("Workspace request failed")
expect(aborted).toBe(true)
})
test("workspace requests preserve non-timeout failures", async () => {
await expect(
workspaceRequestWithTimeout(() => Promise.reject(new Error("Server failed")), "Workspace request failed", 1_000),
).rejects.toThrow("Server failed")
})
test("workspace request timeout settles when the request ignores abort", async () => {
await expect(
workspaceRequestWithTimeout(() => new Promise<never>(() => {}), "Workspace request failed", 1),
).rejects.toThrow("Workspace request 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,35 @@
import { describe, expect, test } from "bun:test"
import { isWorkspaceDirectory, isWorkspaceSelection } 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)
})
test("does not classify unknown directories", () => {
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)
})
test("rejects selections from a different project", () => {
expect(isWorkspaceSelection(project, "/other/workspace")).toBe(false)
})
})

View file

@ -0,0 +1,25 @@
import { pathKey } from "@/utils/path-key"
type WorkspaceProject = { worktree: string; sandboxes?: readonly string[] }
export function isWorkspaceDirectory(project: WorkspaceProject | undefined, directory: string) {
if (!project || containsDirectory(project.worktree, directory)) return false
return project.sandboxes?.some((workspace) => containsDirectory(workspace, directory)) ?? false
}
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 (pathKey(project.worktree) === pathKey(selection)) return true
return isWorkspaceDirectory(project, selection)
}