mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 15:34:17 +00:00
feat(app): support drafting during worktree creation (#46694)
This commit is contained in:
parent
48927df2ff
commit
49f9a60087
17 changed files with 379 additions and 29 deletions
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
55
packages/app/src/session/preparing-composer.tsx
Normal file
55
packages/app/src/session/preparing-composer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue