fix(app): show sessions while worktrees are created (#45529)

This commit is contained in:
Luke Parker
2026-08-27 19:44:23 +10:00
committed by GitHub
parent b1374978c9
commit 42422a1e03
10 changed files with 544 additions and 16 deletions
@@ -0,0 +1,303 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/util/encode"
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const directory = "C:/OpenCode/WorkspacePending"
const workspace = "C:/OpenCode/pending-workspace"
const projectID = "proj_workspace_pending"
const draftID = "draft_workspace_pending"
const otherID = "ses_workspace_pending_other"
const text = "Create the workspace, then explain the pending session."
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionPath = `/server/${base64Encode(server)}/session/`
const draftPath = `/new-session?draftId=${draftID}`
const headers = { "access-control-allow-origin": "*" }
test.use({ serviceWorkers: "block", viewport: { width: 1280, height: 900 } })
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 },
]) {
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await testInfo.attach("creating-worktree", {
body: await page.screenshot({ path: testInfo.outputPath(`pending-${viewport.name}.png`) }),
contentType: "image/png",
})
if (viewport.name === "mobile") {
await page.locator("html").evaluate((element) => {
element.dir = "rtl"
})
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
expect(
await page
.locator('[data-component="session-preparing"]')
.evaluate((element) => element.scrollWidth <= element.clientWidth),
).toBe(true)
}
if (viewport.name === "desktop") {
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(pending.shimmer).toBeHidden()
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
await expect(page).toHaveURL(pending.url)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
expect(mock.calls).toEqual(["worktree"])
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await page.locator('[data-component="composer-editor"]').fill("Keep focus in this other session")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
}
expect(mock.calls).toEqual(["worktree"])
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect
.poll(() => mock.prompts)
.toEqual([{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) }])
expect(mock.creates).toEqual([
expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } }),
])
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
if (viewport.name === "desktop") {
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toContainText(
"Created workspace session",
)
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep focus in this other session")
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`).click()
}
await expect(page).toHaveURL(pending.url)
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(1)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
})
}
test("restores the original draft when worktree creation fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed in the fixture" } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
await expect(page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)).toHaveCount(0)
expect(mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
})
test("retains the draft and reuses the created workspace after session creation fails", async ({ page }) => {
const mock = await openDraft(page, { failSessionCreate: true })
const pending = await submitPending(page, mock)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create session", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "pending-workspace", exact: true })).toBeVisible()
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
expect(mock.creates).toEqual([expect.objectContaining({ id: pending.sessionID, location: { directory: workspace } })])
expect(mock.calls).toEqual(["worktree", "session"])
expect(mock.prompts).toEqual([])
await page.locator('[data-action="composer-submit"]').click()
await expect.poll(() => mock.prompts.length).toBe(1)
expect(mock.creates).toHaveLength(2)
expect(mock.creates[1]).toMatchObject({ location: { directory: workspace } })
expect(mock.prompts[0]).toMatchObject({ sessionID: mock.creates[1].id, body: { text } })
expect(mock.calls).toEqual(["worktree", "session", "session", "prompt"])
await expect(page).toHaveURL(`${sessionPath}${mock.creates[1].id}`)
await expect(page.locator('[data-component="user-message"] [data-slot="user-message-text"]')).toHaveText(text)
})
test("restores the draft after closing and revisiting a pending session that fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
const tab = page.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await page.locator("[data-titlebar-tab-slot]").filter({ has: tab }).locator('[data-slot="tab-close"] button').click()
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(tab).toHaveCount(0)
await expect(pending.shimmer).toHaveCount(0)
await page.goBack()
await expect(page).toHaveURL(pending.url)
await expect(tab).toHaveCount(1)
await expect(tab).toBeVisible()
await expect(pending.message).toHaveCount(1)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(pending.message).toHaveAttribute("data-timeline-part-id", `${pending.messageID}:text:0`)
await expect(pending.shimmer).toBeVisible()
await expect(pending.shimmer).toContainText("Creating worktree")
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
expect(mock.calls).toEqual(["worktree"])
mock.worktree.resolve({ status: 500, json: { message: "Worktree creation failed after revisiting the session" } })
await expect(page).toHaveURL(draftPath)
await expect(page.getByText("Failed to create worktree", { exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator(`[data-titlebar-tab-link][href="${draftPath}"]`)).toHaveCount(1)
await expect(tab).toHaveCount(0)
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message).toHaveCount(0)
expect(mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
})
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const project = {
id: projectID,
worktree: directory,
vcs: "git",
name: "workspace-pending",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [] as string[],
}
const sessions = [currentSession({ id: otherID, projectID, title: "Other session" }, directory)]
await mockOpenCodeServer(page, {
directory,
project,
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { "pending-model": { id: "pending-model", name: "Pending Model", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "pending-model" },
},
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
})
page.on("request", (request) => {
if (request.method() !== "POST") return
const path = new URL(request.url()).pathname
if (path === `/api/worktree/${projectID}`) calls.push("worktree")
if (path === "/api/session") calls.push("session")
if (/^\/api\/session\/[^/]+\/prompt$/.test(path)) calls.push("prompt")
})
await page.route(`**/api/worktree/${projectID}`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
// Keep the real HTTP response pending until the test has checked the preview.
const response = await worktree.promise
if (response.status === 200) project.sandboxes.push(workspace)
await route.fulfill({ ...response, headers })
})
await page.route("**/api/session", async (route) => {
if (route.request().method() !== "POST") return route.fallback()
const body: Record<string, unknown> = route.request().postDataJSON()
creates.push(body)
if (options?.failSessionCreate && creates.length === 1) {
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
}
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
sessions.push(session)
return route.fulfill({ json: { data: session }, headers })
})
await page.route("**/api/location?**", (route) => {
if (route.request().method() !== "GET") return route.fallback()
return route.fulfill({
json: {
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
project: { id: projectID, directory, canonical: directory },
},
headers,
})
})
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "draft", draftID, server, directory },
{ type: "session", sessionId: otherID, server },
]),
)
},
{ directory, draftID, otherID, server },
)
await page.goto(draftPath)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await expect(page.getByRole("button", { name: "New workspace", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, calls, creates, prompts }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>) {
await page.locator('[data-component="composer-editor"]').fill(text)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await page.locator('[data-action="composer-submit"]').click()
await expect(page).toHaveURL((url) => url.pathname.startsWith(sessionPath) && /\/ses_[^/]+$/.test(url.pathname))
const url = page.url()
const sessionID = new URL(url).pathname.slice(sessionPath.length)
const preparing = page.locator('[data-component="session-preparing"]')
const message = page.locator('[data-component="user-message"]')
const shimmer = preparing.getByRole("status").locator('[data-component="text-shimmer"]')
await expect(preparing).toBeVisible()
await expect(preparing.locator('[data-component="user-message"]')).toHaveCount(1)
await expect(message).toHaveCount(1)
await expect(message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(message).toHaveAttribute("data-timeline-part-id", /^.+:text:0$/)
const messageID = (await message.getAttribute("data-timeline-part-id"))!.replace(/:text:0$/, "")
await expect(shimmer).toBeVisible()
await expect(shimmer).toContainText("Creating worktree")
await expect(shimmer).toHaveAttribute("data-active", "true")
await expect.poll(() => mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
return { url, sessionID, messageID, message, shimmer }
}
+2 -1
View File
@@ -103,7 +103,8 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
start: (
selection: ComposerSelection,
submission: ReturnType<typeof createComposerSubmission>,
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
message: SessionMessageUser,
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void>; complete?: () => Promise<void> } | undefined>
}
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
+44
View File
@@ -229,6 +229,50 @@ describe("Composer submission", () => {
})
})
test("previews the first prompt while starting and hands it off before completing preparation", async () => {
const draft = createMemoryComposerState({ prompt: "prepare my worktree" }).capture()
const preview = Promise.withResolvers<SessionMessageUser>()
const ready = Promise.withResolvers<void>()
const calls: string[] = []
const handoff: SessionMessageUser[] = []
const target = session({
calls,
handoff: { set: (message) => handoff.push(message), clear() {} },
prompt: async () => undefined,
})
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
ready: () => true,
controls,
working: () => false,
submitted() {},
async start(_selection, _submission, message) {
preview.resolve(message)
await ready.promise
return {
session: target,
cleanupReady: Promise.resolve(),
async complete() {
expect(handoff).toHaveLength(1)
expect(handoff[0]?.id).toBe(message.id)
expect(handoff[0]?.text).toBe("prepare my worktree")
calls.push("complete")
},
}
},
}
const submitted = submitInput(adapter).submit(new Event("submit"))
expect(await preview.promise).toMatchObject({ type: "user", text: "prepare my worktree" })
expect(calls).toEqual([])
expect(draft.current()).toMatchObject([{ content: "prepare my worktree" }])
ready.resolve()
await submitted
expect(calls).toContain("complete")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {
const state = createMemoryComposerState({ prompt: "admitted prompt" }).capture()
const checked = Promise.withResolvers<void>()
+17 -3
View File
@@ -70,7 +70,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const started =
input.adapter.kind === "active-session"
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
: await input.adapter.start(value.selection, submission)
: await input.adapter.start(value.selection, submission, handoffMessage(value))
if (!started) return
const session = started.session
@@ -80,7 +80,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
if (value.images.length > 0) session.handoff?.set(handoffMessage(value))
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
@@ -88,6 +88,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
await started.complete?.()
input.adapter.submitted()
submission.context
.filter((item) => !!item.comment?.trim())
@@ -104,6 +105,7 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
}
await started.cleanupReady
await started.complete?.()
input.adapter.submitted()
if (value.mode === "shell") {
@@ -122,7 +124,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
)
return
}
} finally {
submitting.delete(input.adapter.state)
}
@@ -147,6 +148,19 @@ function handoffMessage(value: ComposerSubmission): SessionMessageUser {
})),
metadata: {
displayText: value.text,
comments: value.context.flatMap((item) =>
item.comment?.trim()
? [
{
path: item.path,
comment: item.comment.trim(),
...(item.selection ? { selection: { ...item.selection } } : {}),
...(item.preview !== undefined ? { preview: item.preview } : {}),
...(item.commentOrigin ? { origin: item.commentOrigin } : {}),
},
]
: [],
),
agent: value.selection.agent,
model: {
...value.selection.model,
@@ -1,6 +1,7 @@
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory } from "@opencode-ai/util/path"
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { Session } from "@opencode-ai/schema/session"
import { startTransition } from "solid-js"
import type { NewSessionComposerAdapter } from "@/composer/adapter"
import { useComposerState } from "@/composer/persistence"
@@ -43,20 +44,32 @@ export function createNewSessionComposerAdapter(props: {
controls,
working: () => false,
submitted: props.submitted,
async start(selection, submission) {
async start(selection, submission, message) {
const draftID = props.draftID
const projectDirectory = location().directory
const worktree = props.worktree()
const branch = props.branch()
const id = Session.ID.create()
const pending =
worktree === "create"
? tabs.prepareSession(draftID, { server: server.key, sessionId: id }, { message, selection })
: undefined
await pending?.ready
const sessionDirectory = await resolveSessionDirectory({
projectDirectory,
worktree,
branch: props.branch(),
branch,
data,
serverSDK,
language,
})
if (!sessionDirectory) return
if (!sessionDirectory) {
await pending?.rollback()
return
}
const created = data.session.create({
id,
agent: selection.agent,
model: {
id: selection.model.modelID,
@@ -75,6 +88,13 @@ export function createNewSessionComposerAdapter(props: {
return { ok: false as const, error }
},
)
if (pending && !(await creation).ok) {
// Keep retries on the worktree that was already created, not another new checkout.
data.project.invalidate()
await data.project.sync().catch(() => undefined)
await pending.rollback(sessionDirectory)
return
}
const afterCreation = async <T>(run: () => Promise<T>) => {
const result = await creation
if (!result.ok) throw result.error
@@ -85,13 +105,13 @@ export function createNewSessionComposerAdapter(props: {
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
if (!pending) tabs.updateDraft(draftID, { worktree: undefined, branch: undefined })
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
variant: selection.variant ?? null,
})
tabs.promoteDraft(props.draftID, { server: server.key, sessionId: created.id })
if (!pending) tabs.promoteDraft(draftID, { server: server.key, sessionId: created.id })
submission.retarget(
prompt.capture(
{ dir: base64Encode(sessionDirectory), id: created.id },
@@ -102,6 +122,7 @@ export function createNewSessionComposerAdapter(props: {
return {
cleanupReady,
complete: pending?.complete,
session: {
id: created.id,
directory: sessionDirectory,
+1
View File
@@ -734,6 +734,7 @@ export const dict = {
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
"session.new.worktree.creating": "Creating worktree",
"session.new.workspace.runIn": "Run session in",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository",
+52 -2
View File
@@ -1,9 +1,15 @@
import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { SessionUserMessage } from "@opencode-ai/session-ui/message"
import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { CommentsProvider } from "@/composer/comments"
import { readPromptPresentation } from "@/composer/comment-note"
import { FileProvider } from "@/workspaces/files/model"
import { LocationProvider } from "@/workspaces/location"
import { ModelsProvider } from "@/providers/models/models"
import { useProviders } from "@/providers/catalog/providers"
import { useLanguage } from "@/runtime/i18n/language"
import { useNotification } from "@/shell/notifications/notification"
import { ComposerPersistenceProvider } from "@/composer/persistence"
import { useData, useServer } from "@/runtime/server/current"
@@ -11,7 +17,7 @@ import { ServerConnection } from "@/runtime/server/registry"
import { TerminalProvider } from "@/session/terminal/context"
import { useSettingsCommand } from "@/settings/command"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import { useTabs } from "@/shell/tabs/tabs"
import { useTabs, type PendingSession } from "@/shell/tabs/tabs"
import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
import { SessionPanelFrame } from "./session-frame"
@@ -24,6 +30,8 @@ import { SessionScreen } from "./screen"
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const data = useData()
const server = useServer()
const tabs = useTabs()
const directory = createMemo(() => data.session.get(params.id)?.location.directory)
return (
@@ -32,13 +40,55 @@ export function TargetSessionRouteContent() {
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<ResolvedTargetSessionRoute />
<Show when={tabs.pendingSession(server.key, params.id)} fallback={<ResolvedTargetSessionRoute />}>
{(pending) => <PreparingSession sessionID={params.id} pending={pending()} />}
</Show>
</SessionRouteErrorBoundary>
</ModelsProvider>
</>
)
}
function PreparingSession(props: { sessionID: string; pending: PendingSession }) {
const language = useLanguage()
const providers = useProviders(() => props.pending.draft.directory)
return (
<SessionStatePanel>
<DataProvider
directory={props.pending.draft.directory}
data={{
session: [],
session_status: {},
session_diff: {},
provider: { all: providers.all(), default: providers.default(), connected: [] },
}}
>
<div data-component="session-preparing" class="min-h-0 flex-1 overflow-y-auto">
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pt-5 pb-5 md:px-5">
<SessionUserMessage
sessionID={props.sessionID}
message={props.pending.message}
comments={readPromptPresentation(props.pending.message.metadata)?.comments}
historicalAgent={props.pending.selection.agent}
historicalModel={{
id: props.pending.selection.model.modelID,
providerID: props.pending.selection.model.providerID,
variant: props.pending.selection.variant,
}}
/>
<div
role="status"
class="mt-3 flex min-h-6 items-center text-[13px] font-medium leading-[var(--line-height-compact)] text-v2-text-text-muted"
>
<TextShimmer text={language.t("session.new.worktree.creating")} active />
</div>
</div>
</div>
</DataProvider>
</SessionStatePanel>
)
}
function TargetSessionSettingsCommand() {
useSettingsCommand()
return null
+77 -1
View File
@@ -1,4 +1,5 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import type { SessionInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import type { ComposerSelection } from "@/composer/adapter"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/runtime/persistence/storage"
@@ -34,6 +35,12 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
export type PendingSession = {
draft: DraftTab
message: SessionMessageUser
selection: ComposerSelection
}
export type TabInfo = {
title?: string
directory?: string
@@ -83,6 +90,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createStore<Record<string, TabInfo>>({}),
)
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
const [pending, setPending] = createStore<Record<string, PendingSession | undefined>>({})
const params = useParams()
const navigate = useNavigate()
@@ -271,6 +279,74 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
memory.remove(`draft:${draftID}`)
removeDraftPersisted(draftID)
},
pendingSession(server: ServerConnection.Key, sessionID: string): PendingSession | undefined {
return pending[tabKey({ type: "session", server, sessionId: sessionID })]
},
prepareSession(
draftID: string,
session: Omit<SessionTab, "type">,
preview: { message: SessionMessageUser; selection: ComposerSelection },
) {
// Snapshot the draft before replacing its store entry; keep its composer alive for rollback.
const draft = { ...actions.draft(draftID) }
const next = { type: "session" as const, ...session }
const key = tabKey(next)
const ready = startTransition(() => {
setPending(key, { draft, ...preview })
const index = store.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index === -1) return
const active = location.pathname === "/new-session" && location.query.draftId === draftID
setStore(
produce((tabs) => {
tabs[index] = next
}),
)
if (recentKey() === tabKey(draft)) setRecentKey(key)
if (active) navigateTab(next)
})
return {
ready,
async complete() {
await ready
if (!pending[key]) return
await startTransition(() => setPending(key, undefined))
memory.remove(tabKey(draft))
removeDraftPersisted(draftID)
},
async rollback(worktree?: string) {
await ready
if (!pending[key]) return
await startTransition(() => {
const index = store.findIndex((tab) => tabKey(tab) === key)
if (index !== -1) {
const restored = worktree === undefined ? draft : { ...draft, worktree, branch: undefined }
const route = currentRoute()
setStore(
produce((tabs) => {
tabs[index] = restored
}),
)
if (recentKey() === key) setRecentKey(tabKey(restored))
if (
route.type === "session" &&
route.server === session.server &&
route.sessionId === session.sessionId
) {
navigateTab(restored)
}
}
setPending(key, undefined)
})
updateClosed((stack) => removeClosedTabs(stack, session.server, [session.sessionId]))
memory.remove(key)
removeInfo(key)
if (store.some((tab) => tab.type === "draft" && tab.draftID === draftID)) return
memory.remove(tabKey(draft))
removeDraftPersisted(draftID)
},
}
},
removeTab,
// User-initiated close: records the tab so it can be reopened.
// Cleanup paths (missing sessions, archive, server removal) go through
+10 -4
View File
@@ -92,10 +92,12 @@ function SessionTabEntry(props: {
const tabs = useTabs()
const language = useLanguage()
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
const pending = createMemo(() => tabs.pendingSession(props.tab.server, props.tab.sessionId))
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
if (pending()) return null
const ctx = props.serverCtx
return ctx ? { id: props.tab.sessionId, ctx } : null
},
@@ -105,9 +107,9 @@ function SessionTabEntry(props: {
.then(() => ctx.data.session.get(id))
.catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title)
const session = createMemo(() => (pending() ? undefined : (cachedSession() ?? loadedSession())))
const missingSession = createMemo(() => !pending() && !!props.serverCtx && !loadedSession.loading && !session())
const visible = createMemo(() => !!pending() || !!session() || missingSession() || !!persisted()?.title)
const rename = async (title: string) => {
const value = session()
@@ -170,7 +172,11 @@ function SessionTabEntry(props: {
forceTruncate={props.forceTruncate}
orientation={props.orientation}
session={session()}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
fallbackTitle={
pending()
? language.t("command.session.new")
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
}
onRename={rename}
onNavigate={props.onNavigate}
onClose={props.onClose}
@@ -161,6 +161,7 @@ export function Titlebar(props: {
() => {
const route = layout.route()
if (route.type !== "session") return undefined
if (tabs.pendingSession(route.server, route.sessionId)) return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
@@ -169,6 +170,7 @@ export function Titlebar(props: {
const session = createMemo(() => {
const route = layout.route()
if (route.type !== "session") return
if (tabs.pendingSession(route.server, route.sessionId)) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
if (cached) return cached
@@ -220,6 +222,10 @@ export function Titlebar(props: {
}
if (route.type === "session") {
if (tabs.pendingSession(route.server, route.sessionId)) {
tabsStoreActions.addSessionTab({ server: route.server, sessionId: route.sessionId })
return
}
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
@@ -238,6 +244,12 @@ export function Titlebar(props: {
const route = layout.route()
switch (route.type) {
case "session": {
const pending = tabs.pendingSession(route.server, route.sessionId)
if (pending) {
const model = tabs.stateValue<ComposerState>(pending.draft, "prompt")?.model.current()
void tabs.newDraft({ server: route.server, directory: pending.draft.directory }, "", model)
return
}
const activeSession = session()
if (!activeSession) return