From 49f9a60087cf55c185403cb0a75170205137232f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:29:45 +1000 Subject: [PATCH] feat(app): support drafting during worktree creation (#46694) --- .../new-session-workspace-pending.spec.ts | 194 +++++++++++++++++- packages/app/src/composer/editor/editor.tsx | 6 +- .../app/src/composer/editor/interaction.ts | 4 + packages/app/src/composer/prompt-parts.ts | 12 ++ packages/app/src/composer/submission-state.ts | 21 +- packages/app/src/composer/submit.test.ts | 7 +- packages/app/src/composer/submit.ts | 3 +- .../app/src/new-session/composer-adapter.ts | 3 +- packages/app/src/runtime/i18n/en.ts | 1 + .../app/src/session/preparing-composer.tsx | 55 +++++ packages/app/src/session/route.tsx | 5 +- .../src/session/session-identity-header.tsx | 19 +- packages/app/src/shell/tabs/tabs.tsx | 22 +- packages/app/src/shell/titlebar/tab-nav.tsx | 11 +- packages/app/src/shell/titlebar/tab-strip.tsx | 3 + packages/app/src/shell/titlebar/titlebar.tsx | 24 ++- .../prompt-submission-state.test.ts | 18 ++ 17 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 packages/app/src/session/preparing-composer.tsx diff --git a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts index ea76fb8ceb4..70e20c41ec1 100644 --- a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts +++ b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts @@ -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("[data-session-title] h1") + const message = document.querySelector('[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 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 } } diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 96bf45da042..fecd0703bf0 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -91,7 +91,7 @@ export function ComposerEditor(props: ComposerEditorProps) { event.currentTarget.value = "" }} /> - + 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()} > 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" }) }, diff --git a/packages/app/src/composer/prompt-parts.ts b/packages/app/src/composer/prompt-parts.ts index 1455170c3ee..e616b484b83 100644 --- a/packages/app/src/composer/prompt-parts.ts +++ b/packages/app/src/composer/prompt-parts.ts @@ -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 }, + ), + ] +} diff --git a/packages/app/src/composer/submission-state.ts b/packages/app/src/composer/submission-state.ts index 5428114f6d1..e1cc4d622b7 100644 --- a/packages/app/src/composer/submission-state.ts +++ b/packages/app/src/composer/submission-state.ts @@ -1,4 +1,5 @@ import type { ComposerState, ContextItem, Prompt } from "./state" +import { appendPrompt, clonePrompt } from "./prompt-parts" export type ComposerStateTarget = ReturnType @@ -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, + } }, } } diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index 2f178cf735b..178dcd0db77 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -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() const ready = Promise.withResolvers() 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 () => { diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 83bdf4e90a0..e7a344a0fb3 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -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, diff --git a/packages/app/src/new-session/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 0b17dbdf4f0..a1d4e1b09fd 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -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, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 2631d96fcbe..0f2a43be0cc 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/session/preparing-composer.tsx b/packages/app/src/session/preparing-composer.tsx new file mode 100644 index 00000000000..51c227b8223 --- /dev/null +++ b/packages/app/src/session/preparing-composer.tsx @@ -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('[data-component="composer-editor"]') + if (!next || next === element) return + next.focus() + setCursorPosition(next, cursor ?? 0) + }) + }) + }, + ), + ) + + return ( +
+
+ +
+
+ ) +} diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 9cdb3d07d22..a68ea951902 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -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 }) }} >
-
+ +
+ ) diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 23a9b308b27..f0c1528f3f7 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -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() diff --git a/packages/app/src/shell/tabs/tabs.tsx b/packages/app/src/shell/tabs/tabs.tsx index 26c73c131af..68fefd6e0bd 100644 --- a/packages/app/src/shell/tabs/tabs.tsx +++ b/packages/app/src/shell/tabs/tabs.tsx @@ -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) { await ready if (!pending[key]) return + await memory.get(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(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) { diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index 42e16c5d662..e3be86069de 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -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 onClose: () => void @@ -239,7 +241,14 @@ export function TabNavItem(props: { when={props.session} keyed fallback={ -