feat(app): support drafting during worktree creation (#46694)

This commit is contained in:
Luke Parker 2026-09-02 11:29:45 +10:00 committed by GitHub
parent 48927df2ff
commit 49f9a60087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 379 additions and 29 deletions

View file

@ -9,12 +9,17 @@ 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 followUp = "Then explain the setup scripts.\nInclude the install command."
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 } })
test.use({
serviceWorkers: "block",
viewport: { width: 1280, height: 900 },
permissions: ["clipboard-read", "clipboard-write"],
})
for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
@ -24,22 +29,44 @@ for (const viewport of [
await page.setViewportSize(viewport)
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
const editor = page.locator('[data-component="composer-editor"]')
await draftFollowUp(page)
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
await editor.press("Enter")
await editor.press("ControlOrMeta+Enter")
await page.locator('[data-component="composer"]').dispatchEvent("submit")
await expect(editor).toHaveText(followUp)
await expect(editor).toBeInViewport()
expect(mock.worktreeRequests).toEqual([expect.objectContaining({ from: directory })])
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(pending.title).toBeInViewport()
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
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('[data-slot="mobile-tabs-trigger"]').click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator('[data-component="session-progress-indicator-v2"]')).toBeVisible()
await tab.click()
await expect(drawer).toBeHidden()
await page.locator("html").evaluate((element) => {
element.dir = "rtl"
element.setAttribute("dir", "rtl")
})
await expect(page.locator("html")).toHaveAttribute("dir", "rtl")
await expect(pending.message).toBeInViewport()
await expect(pending.shimmer).toBeInViewport()
await expect(pending.title).toBeInViewport()
await expect(page.locator('[data-component="session-preparing"]')).toHaveCSS("direction", "rtl")
expect(
await page
@ -53,11 +80,14 @@ for (const viewport of [
await expect(page).toHaveURL(`${sessionPath}${otherID}`)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(pending.shimmer).toBeHidden()
await expect(spinner).toBeVisible()
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")
await expect(pending.title).toHaveText("New session")
await expect(editor).toHaveText(followUp)
expect(mock.calls).toEqual(["worktree"])
await page.locator(`[data-titlebar-tab-link][href="${sessionPath}${otherID}"]`).click()
@ -88,12 +118,133 @@ for (const viewport of [
await expect(page).toHaveURL(pending.url)
await expect(pending.shimmer).toHaveCount(0)
await expect(spinner).toHaveCount(1)
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(editor).toHaveText(followUp)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
expect(mock.prompts).toHaveLength(1)
await page.locator('[data-action="composer-submit"]').click()
await expect
.poll(() => mock.prompts)
.toEqual([
{ sessionID: pending.sessionID, body: expect.objectContaining({ id: pending.messageID, text }) },
{ sessionID: pending.sessionID, body: expect.objectContaining({ text: followUp }) },
])
})
}
for (const direction of ["ltr", "rtl"]) {
test(`keeps the title and message stable through worktree creation in ${direction}`, async ({ page }) => {
const mock = await openDraft(page)
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), direction)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
await page.locator('[data-component="composer-editor"]').press("ControlOrMeta+Home")
const title = page.locator("[data-session-title]").getByRole("heading", { level: 1 })
const before = await title.boundingBox()
const messageBefore = await pending.message.boundingBox()
// Observe painted frames during the handoff, without using frame counts to wait for readiness.
const observation = await page.evaluateHandle(() => {
const frames: { title: string | null; message: boolean; spinner: boolean; draft: string | null }[] = []
let frame = 0
const sample = () => {
const title = document.querySelector<HTMLElement>("[data-session-title] h1")
const message = document.querySelector<HTMLElement>('[data-component="user-message"]')
const spinner = document.querySelector(
'[data-titlebar-tab-slot][data-active="true"] [data-component="session-progress-indicator-v2"]',
)
const editor = document.querySelector('[data-component="composer-editor"]')
frames.push({
title: title?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? title.textContent : null,
message: !!message?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
spinner: !!spinner?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }),
draft: editor?.checkVisibility({ checkVisibilityCSS: true, checkOpacity: true }) ? editor.textContent : null,
})
frame = requestAnimationFrame(sample)
}
sample()
return {
stop: () => {
cancelAnimationFrame(frame)
return frames
},
}
})
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(title).toHaveText("Created workspace session")
await expect(pending.shimmer).toHaveCount(0)
await expect(pending.message.locator('[data-slot="user-message-text"]')).toHaveText(text)
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
const frames = await observation.evaluate((observation) => observation.stop())
await observation.dispose()
expect(frames.length).toBeGreaterThan(0)
expect(
frames.filter(
(frame) =>
!frame.message ||
!frame.spinner ||
frame.draft !== followUp ||
!["New session", "Created workspace session"].includes(frame.title ?? ""),
),
).toEqual([])
const after = await title.boundingBox()
const messageAfter = await pending.message.boundingBox()
expect(after?.y).toBe(before?.y)
expect(after?.height).toBe(before?.height)
expect(messageAfter).toEqual(messageBefore)
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
})
}
for (const failure of ["worktree", "session"]) {
test(`preserves both inputs when ${failure} creation fails`, async ({ page }) => {
const mock = await openDraft(page, { failSessionCreate: failure === "session" })
const pending = await submitPending(page, mock)
await draftFollowUp(page)
mock.worktree.resolve(
failure === "worktree"
? { status: 500, json: { message: "Worktree creation failed" } }
: { status: 200, json: { directory: workspace } },
)
await expect(page).toHaveURL(draftPath)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(pending.shimmer).toHaveCount(0)
expect(mock.prompts).toEqual([])
})
}
test("preserves both inputs when the initial prompt cannot be sent", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
await draftFollowUp(page)
await page.route(`**/api/session/${pending.sessionID}/prompt`, (route) =>
route.fulfill({
status: 500,
json: { message: "Prompt admission failed" },
headers,
}),
)
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt", "prompt"])
expect(mock.prompts).toEqual([])
})
test("restores the original draft when worktree creation fails", async ({ page }) => {
const mock = await openDraft(page)
const pending = await submitPending(page, mock)
@ -144,6 +295,7 @@ test("retains the draft and reuses the created workspace after session creation
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)
await draftFollowUp(page)
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()
@ -164,13 +316,14 @@ test("restores the draft after closing and revisiting a pending session that fai
await expect(pending.shimmer).toBeVisible()
await expect(pending.shimmer).toContainText("Creating worktree")
await expect(pending.shimmer).toHaveAttribute("data-active", "true")
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(followUp)
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-component="composer-editor"]')).toHaveText(`${text}\n\n${followUp}`)
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)
@ -182,6 +335,17 @@ test("restores the draft after closing and revisiting a pending session that fai
expect(mock.prompts).toEqual([])
})
async function draftFollowUp(page: Page) {
const editor = page.locator('[data-component="composer-editor"]')
await editor.pressSequentially("!")
await expect(editor).toHaveText("!")
await expect(editor).toHaveAttribute("dir", "auto")
await editor.fill("")
await page.evaluate((text) => navigator.clipboard.writeText(text), followUp)
await editor.press("ControlOrMeta+V")
await expect(editor).toHaveText(followUp)
}
async function openDraft(page: Page, options?: { failSessionCreate?: boolean }) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
@ -254,6 +418,24 @@ async function openDraft(page: Page, options?: { failSessionCreate?: boolean })
headers,
})
})
await page.route("**/api/agent?**", (route) =>
route.fulfill({
json: {
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
data: [
{
id: "build",
name: "Build",
mode: "primary",
hidden: false,
request: { settings: {}, headers: {}, body: {} },
permissions: [],
},
],
},
headers,
}),
)
await page.addInitScript(
({ directory, draftID, otherID, server }) => {
localStorage.setItem(
@ -292,7 +474,11 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
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"]')
const title = preparing.getByRole("heading", { level: 1 })
await expect(preparing).toBeVisible()
await expect(title).toHaveText("New session")
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(page.locator('[data-action="composer-submit"]')).toBeDisabled()
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)
@ -304,5 +490,5 @@ async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDra
await expect.poll(() => mock.calls).toEqual(["worktree"])
expect(mock.creates).toEqual([])
expect(mock.prompts).toEqual([])
return { url, sessionID, messageID, message, shimmer }
return { url, sessionID, messageID, message, shimmer, title }
}

View file

@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
event.currentTarget.value = ""
}}
/>
<Show when={state.popover.type !== "closed"}>
<Show when={!view.draftOnly && state.popover.type !== "closed"}>
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
@ -185,7 +185,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (!view.draftOnly && props.controller.onKeyDown(event)) return
const mod = event.metaKey || event.ctrlKey
if (mod && event.key === "ArrowUp" && !event.shiftKey && !event.altKey) {
if (view.submit.queue?.editFirst()) event.preventDefault()
@ -238,7 +238,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
style={buttons()}
>
<ComposerEditorAddMenu
disabled={state.mode === "shell"}
disabled={view.draftOnly || state.mode === "shell"}
title={i18n.t("ui.promptInput.add")}
keybind={props.attachKeybind ?? ["Mod", "U"]}
attachLabel={i18n.t("ui.promptInput.attachments")}

View file

@ -29,6 +29,7 @@ export type ComposerSelectControl = {
}
export type ComposerEditorView = {
draftOnly?: boolean
placeholder?: Accessor<string>
add?: {
onAttach: () => void
@ -332,6 +333,7 @@ export function createComposerEditor(input: {
draft.removeAttachment(id)
},
canSubmit() {
if (input.view.draftOnly) return false
const persisted = draft.state
if (state.mode === "shell") {
return persisted.prompt.some((part) => "content" in part && !!part.content.trim())
@ -347,6 +349,7 @@ export function createComposerEditor(input: {
restoreFocus,
onInput(value: string, prompt?: ComposerPersistedState["prompt"], cursor?: number) {
if (prompt) draft.setPrompt(prompt, cursor)
if (input.view.draftOnly) return
dispatch({ type: "input.changed", value, persist: !prompt })
},
onCursor(cursor: number) {
@ -362,6 +365,7 @@ export function createComposerEditor(input: {
dispatch({ type: "mode.shell" })
},
submit(options?: { alternate?: boolean }) {
if (input.view.draftOnly) return
input.view.submit.onSubmit(options)
dispatch({ type: "popover.close" })
},

View file

@ -9,3 +9,15 @@ export function clonePrompt(prompt: Prompt): Prompt {
export function promptLength(prompt: Prompt) {
return prompt.reduce((length, part) => length + ("content" in part ? part.content.length : 0), 0)
}
export function appendPrompt(prompt: Prompt, following: Prompt): Prompt {
const start = promptLength(prompt)
const offset = start + 2
return [
...clonePrompt(prompt),
{ type: "text", content: "\n\n", start, end: offset },
...clonePrompt(following).map((part) =>
part.type === "image" ? part : { ...part, start: part.start + offset, end: part.end + offset },
),
]
}

View file

@ -1,4 +1,5 @@
import type { ComposerState, ContextItem, Prompt } from "./state"
import { appendPrompt, clonePrompt } from "./prompt-parts"
export type ComposerStateTarget = ReturnType<ComposerState["capture"]>
@ -10,24 +11,36 @@ export function createComposerSubmission(input: {
const initial = input.target
let target = input.target
let cleared: Prompt | undefined
let following: Prompt | undefined
let preserveDraft = false
return {
prompt: input.prompt,
context: input.context,
target: () => target,
clear() {
if (initial !== target) initial.reset()
target.reset()
if (initial !== target) {
initial.reset()
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => part.type === "image" || part.content.length > 0))
following = clonePrompt(target.current())
}
if (!following) target.reset()
cleared = target.current()
},
retarget(next: ComposerStateTarget) {
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
input.context.forEach((item) => next.context.add(item))
target = next
preserveDraft = options?.preserveDraft ?? false
},
current: (value: ComposerStateTarget) => target === value,
restore() {
if (cleared !== undefined && target.current() !== cleared) return
return { target, prompt: input.prompt, context: input.context }
return {
target,
prompt: following ? appendPrompt(input.prompt, following) : input.prompt,
context: input.context,
}
},
}
}

View file

@ -231,6 +231,7 @@ 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 promoted = createMemoryComposerState().capture()
const preview = Promise.withResolvers<SessionMessageUser>()
const ready = Promise.withResolvers<void>()
const calls: string[] = []
@ -247,9 +248,10 @@ describe("Composer submission", () => {
controls,
working: () => false,
submitted() {},
async start(_selection, _submission, message) {
async start(_selection, submission, message) {
preview.resolve(message)
await ready.promise
submission.retarget(promoted, { preserveDraft: true })
return {
session: target,
cleanupReady: Promise.resolve(),
@ -257,6 +259,8 @@ describe("Composer submission", () => {
expect(handoff).toHaveLength(1)
expect(handoff[0]?.id).toBe(message.id)
expect(handoff[0]?.text).toBe("prepare my worktree")
expect(promoted.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
promoted.set([{ type: "text", content: "follow up", start: 0, end: 9 }], 9)
calls.push("complete")
},
}
@ -271,6 +275,7 @@ describe("Composer submission", () => {
await submitted
expect(calls).toContain("complete")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(promoted.current()).toEqual([{ type: "text", content: "follow up", start: 0, end: 9 }])
})
test("does not restore a prompt already acknowledged by the durable inbox", async () => {

View file

@ -248,7 +248,8 @@ function restoreSubmission(
preview: item.preview,
})),
)
if (value.mode === "normal") {
// A recovered follow-up changes the payload, so it must use a new admission ID.
if (value.mode === "normal" && restored.prompt === submission.prompt) {
restored.target.retry.set({
id: value.id,
agent: value.selection.agent,

View file

@ -117,12 +117,13 @@ export function createNewSessionComposerAdapter(props: {
{ dir: base64Encode(sessionDirectory), id: created.id },
{ server: server.key, scope: serverSDK.scope },
),
{ preserveDraft: !!pending },
)
})
return {
cleanupReady,
complete: pending?.complete,
complete: pending ? () => pending.complete(submission.target()) : undefined,
session: {
id: created.id,
directory: sessionDirectory,

View file

@ -744,6 +744,7 @@ export const dict = {
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
"session.new.worktree.create": "Create new worktree",
"session.new.worktree.creating": "Creating worktree",
"session.new.worktree.draftPlaceholder": "Draft a follow-up while the worktree is being prepared...",
"session.new.workspace.runIn": "Run session in",
"session.new.workspace.triggerLocal": "Local",
"session.new.workspace.local": "Local repository",

View file

@ -0,0 +1,55 @@
import { createEffect, on, onCleanup } from "solid-js"
import { useLocation } from "@solidjs/router"
import { ComposerEditor } from "@/composer/editor/editor"
import { setCursorPosition } from "@/composer/editor/dom"
import { createComposerEditor } from "@/composer/editor/interaction"
import { useLanguage } from "@/runtime/i18n/language"
import type { PendingSession } from "@/shell/tabs/tabs"
export function PreparingComposer(props: { pending: PendingSession }) {
const language = useLanguage()
const location = useLocation()
let element: HTMLElement | undefined
const editor = createComposerEditor({
store: () => props.pending.composer.store,
commands: () => [],
context: () => [],
searchContextFiles: () => [],
onEditor: (value) => {
element = value
},
view: {
draftOnly: true,
placeholder: () => language.t("session.new.worktree.draftPlaceholder"),
submit: { stopping: () => false, onSubmit() {}, onStop() {} },
},
})
createEffect(
on(
() => props.pending,
(pending) => {
const pathname = location.pathname
editor.restoreFocus(pending.composer.cursor())
onCleanup(() => {
if (document.activeElement !== element) return
const cursor = pending.composer.cursor()
requestAnimationFrame(() => {
if (location.pathname !== pathname) return
const next = document.querySelector<HTMLDivElement>('[data-component="composer-editor"]')
if (!next || next === element) return
next.focus()
setCursorPosition(next, cursor ?? 0)
})
})
},
),
)
return (
<div data-component="session-composer-dock" class="w-full shrink-0 bg-v2-background-bg-base pb-3">
<div class="mx-auto w-full max-w-[1000px] px-3">
<ComposerEditor controller={editor} modelControlsVisible={false} />
</div>
</div>
)
}

View file

@ -26,6 +26,7 @@ import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
import { SessionScreen } from "./screen"
import { PreparingComposer } from "./preparing-composer"
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
@ -64,7 +65,8 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
}}
>
<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">
<SessionIdentityHeader sessionID={props.sessionID} />
<div class="mx-auto w-full min-w-0 max-w-[1000px] px-4 pb-5 md:px-5">
<SessionUserMessage
sessionID={props.sessionID}
message={props.pending.message}
@ -84,6 +86,7 @@ function PreparingSession(props: { sessionID: string; pending: PendingSession })
</div>
</div>
</div>
<PreparingComposer pending={props.pending} />
</DataProvider>
</SessionStatePanel>
)

View file

@ -4,6 +4,7 @@ import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { useNavigate } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { useLanguage } from "@/runtime/i18n/language"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
@ -27,6 +28,8 @@ export function SessionTitleHeader(props: ParentProps) {
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const language = useLanguage()
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
const settings = useSettings()
const navigate = useNavigate()
const tab = createMemo(() =>
@ -54,10 +57,18 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
const parentTitle = createMemo(() => {
const id = parentID()
const current = tab()
return sessionTitle(parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined))
return sessionTitle(
parent()?.title ?? (current?.type === "session" && current.sessionId === id ? info()?.title : undefined),
)
})
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)))
const directory = createMemo(
() => props.session?.location.directory ?? pending()?.draft.directory ?? info()?.directory,
)
const title = createMemo(() =>
pending()
? language.t("command.session.new")
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
)
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
@ -70,7 +81,7 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
})
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
const navigateParent = () => {
const id = parentID()
const current = tab()

View file

@ -12,7 +12,13 @@ import { SessionTabsRemovedDetail } from "@/shell/titlebar/session-events"
import { sessionHref } from "@/shell/routes/session"
import { createTabMemory } from "./memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
import { createDraftComposerState, type PromptModel } from "@/composer/state"
import {
createDraftComposerState,
createMemoryComposerState,
type ComposerState,
type PromptModel,
} from "@/composer/state"
import { appendPrompt, promptLength } from "@/composer/prompt-parts"
import { TabStorage } from "./schema"
import { useCurrentRoute } from "@/shell/state/layout"
@ -24,6 +30,7 @@ export type PendingSession = {
draft: DraftTab
message: SessionMessageUser
selection: ComposerSelection
composer: ComposerState
}
export type TabInfo = typeof TabStorage.Info.Type
@ -285,8 +292,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const draft = { ...actions.draft(draftID) }
const next = { type: "session" as const, ...session }
const key = tabKey(next)
const composer = createMemoryComposerState()
const ready = startTransition(() => {
setPending(key, { draft, ...preview })
setPending(key, { draft, ...preview, composer })
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
@ -301,9 +309,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
return {
ready,
async complete() {
async complete(target: ReturnType<ComposerState["capture"]>) {
await ready
if (!pending[key]) return
await memory.get<ComposerState>(key, "prompt")?.ready.promise
if (promptLength(composer.current())) target.set(composer.current(), composer.cursor())
await startTransition(() => setPending(key, undefined))
memory.remove(tabKey(draft))
removeDraftPersisted(draftID)
@ -311,6 +321,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
async rollback(worktree?: string) {
await ready
if (!pending[key]) return
const original = memory.get<ComposerState>(tabKey(draft), "prompt")
if (original && promptLength(composer.current())) {
// Nothing was submitted: recover both inputs in the original draft.
const restored = appendPrompt(original.current(), composer.current())
original.set(restored, promptLength(restored))
}
await startTransition(() => {
const index = store.findIndex((tab) => tabKey(tab) === key)
if (index !== -1) {

View file

@ -11,6 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName, projectForSession } from "@/shell/layout/helpers"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/session/title"
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
@ -25,6 +26,7 @@ export function TabNavItem(props: {
href: string
server: ServerConnection.Key
session: SessionInfo | undefined
preparing: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
@ -239,7 +241,14 @@ export function TabNavItem(props: {
when={props.session}
keyed
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
<Show
when={props.preparing}
fallback={
<span class="block size-4 rounded-[3px] border border-v2-border-border-muted" aria-hidden="true" />
}
>
<SessionProgressIndicatorV2 />
</Show>
}
>
{(session) => (

View file

@ -27,6 +27,7 @@ function SessionTabSlot(props: {
active: boolean
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
preparing: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@ -62,6 +63,7 @@ function SessionTabSlot(props: {
href={tabHref(props.tab)}
server={props.tab.server}
session={props.session}
preparing={props.preparing}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
@ -167,6 +169,7 @@ function SessionTabEntry(props: {
active={props.active}
orientation={props.orientation}
session={session()}
preparing={!!pending()}
fallbackTitle={
pending()
? language.t("command.session.new")

View file

@ -28,6 +28,7 @@ import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
import { useSettingsDialog } from "@/settings/command"
@ -171,11 +172,15 @@ export function Titlebar(props: {
const tabs = useTabs()
const tabsStore = tabs.store
const tabsStoreActions = tabs
const preparing = createMemo(() => {
const route = layout.route()
return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId)
})
const [loadedSession] = createResource(
() => {
const route = layout.route()
if (route.type !== "session") return undefined
if (tabs.pendingSession(route.server, route.sessionId)) return undefined
if (preparing()) return undefined
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
},
@ -184,7 +189,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
if (preparing()) 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
@ -441,10 +446,17 @@ export function Titlebar(props: {
tab().type === "draft" ? (
<Icon name="edit" />
) : (
<span
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
aria-hidden="true"
/>
<Show
when={preparing()}
fallback={
<span
class="block size-4 rounded-[3px] border border-v2-border-border-muted"
aria-hidden="true"
/>
}
>
<SessionProgressIndicatorV2 />
</Show>
)
}
>

View file

@ -70,4 +70,22 @@ describe("prompt submission state", () => {
expect(submission.restore()).toBeUndefined()
expect(target.current()[0]).toMatchObject({ type: "text", content: "new draft" })
})
test("preserves a prepared follow-up and recovers both inputs when the first send fails", () => {
const draft = createMemoryComposerState({ prompt: "first prompt" })
const session = createMemoryComposerState({ prompt: "follow-up" })
const submission = createComposerSubmission({ target: draft, prompt: draft.current(), context: [] })
submission.retarget(session, { preserveDraft: true })
submission.clear()
expect(draft.current()[0]).toMatchObject({ content: "" })
expect(session.current()[0]).toMatchObject({ content: "follow-up" })
expect(submission.restore()?.prompt).toEqual([
{ type: "text", content: "first prompt", start: 0, end: 12 },
{ type: "text", content: "\n\n", start: 12, end: 14 },
{ type: "text", content: "follow-up", start: 14, end: 23 },
])
session.set([{ type: "text", content: "edited", start: 0, end: 6 }])
expect(submission.restore()).toBeUndefined()
})
})