diff --git a/packages/app/e2e/regression/session-timeline-working.spec.ts b/packages/app/e2e/regression/session-timeline-working.spec.ts new file mode 100644 index 00000000000..c2e0fa044d2 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-working.spec.ts @@ -0,0 +1,278 @@ +import { expect, test } from "@playwright/test" +import { + assistantID, + assistantMessage, + directory, + partUpdated, + reasoningPart, + sessionID, + setupTimeline, + status, + stepStarted, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const width of [1400, 390]) { + test(`shows Working between busy and reasoning states at ${width}px`, async ({ page }, testInfo) => { + const timeline = await setupTimeline(page, { + messages: [userMessage()], + sessionStatus: { [sessionID]: { type: "busy" } }, + viewport: { width, height: 900 }, + }) + const working = page.locator('[data-component="session-working"]') + await expect(working).toHaveCount(1) + await expect(working).toHaveRole("status") + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working") + await expect(working).toBeInViewport() + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(working.locator('[data-component="text-shimmer"]')).toHaveCSS("line-height", "16px") + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath(`working-${width}.png`) }) + + await timeline.send(stepStarted(assistantMessage([], { completed: false }))) + await expect(working).toBeVisible() + const id = `prt_working_reasoning_${width}` + await timeline.send(partUpdated(reasoningPart(id, ""))) + await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible() + await expect(working).toHaveCount(0) + + await timeline.send(partUpdated(reasoningPart(id, "The inspection is complete."))) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working") + await expect(working).toBeInViewport() + + await timeline.send(status("idle")) + await expect(working).toHaveCount(0) + }) +} + +for (const name of ["shell", "patch", "subagent"] as const) { + test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => { + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([], { completed: false })], + settings: { editToolPartsExpanded: true }, + }) + const working = page.locator('[data-component="session-working"]') + await expect(working).toBeVisible() + + const id = `prt_working_${name}` + const input = + name === "shell" + ? { command: "printf ready" } + : name === "patch" + ? { patchText: "*** Begin Patch\n*** Add File: src/working.ts\n+export const ready = true\n*** End Patch" } + : { agent: "general", description: "Inspect working indicator", prompt: "Inspect the timeline." } + await timeline.send(partUpdated(toolPart(id, name, "streaming", input))) + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + await expect(tool).toBeVisible() + await expect(working).toHaveCount(0) + + const metadata = + name === "patch" + ? { + files: [ + { + file: "src/working.ts", + status: "added", + additions: 1, + deletions: 0, + patch: "@@ -0,0 +1 @@\n+export const ready = true", + }, + ], + } + : {} + await timeline.send(partUpdated(toolPart(id, name, "running", input, { metadata }))) + await expect(tool).toContainText( + name === "shell" ? "printf ready" : name === "patch" ? "working.ts" : "Inspect working indicator", + ) + await expect(working).toHaveCount(0) + + await timeline.send(partUpdated(toolPart(id, name, "completed", input, { metadata }))) + if (name === "shell") { + const group = page.locator('[data-component="collapsed-tool-group"]') + await expect( + group.getByRole("button", { name: "Used 1 Shell", exact: true, includeHidden: true }), + ).toHaveAttribute("aria-expanded", "false") + await expect(group).toBeVisible() + } + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working") + await expect(working).toBeVisible() + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + }) +} + +for (const name of ["read", "shell", "subagent"] as const) { + test(`keeps Working for grouped ${name} regardless of disclosure`, async ({ page }, testInfo) => { + await setupTimeline(page, { + viewport: { width: name === "shell" ? 390 : 1400, height: 900 }, + messages: [ + userMessage(), + assistantMessage( + [ + toolPart("prt_grouped_previous", "read", "completed", { filePath: "package.json" }), + toolPart( + "prt_grouped_active", + name, + "running", + name === "shell" + ? { command: "sleep 10" } + : name === "subagent" + ? { agent: "general", description: "Inspect the timeline", prompt: "Inspect it." } + : { filePath: "src/working.ts" }, + ), + ], + { completed: false }, + ), + ], + }) + const working = page.locator('[data-component="session-working"]') + const group = page.locator('[data-component="collapsed-tool-group"]') + const trigger = group.locator(':scope > [data-component="collapsible"] > [data-slot="collapsible-trigger"]') + await expect(group).toHaveAttribute("data-timeline-part-ids", "prt_grouped_previous,prt_grouped_active") + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await expect(working).toBeInViewport() + if (name !== "read") { + const hint = page.locator('[data-component="session-background-hint"]') + await expect(hint).toBeInViewport() + await expect(page.locator('[data-component="session-background-hint-row"]')).toHaveCSS("height", "24px") + await page.screenshot({ path: testInfo.outputPath(`working-grouped-${name}.png`) }) + } + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + if (name === "subagent") await expect(group.getByText("Inspect the timeline", { exact: true })).toBeVisible() + if (name !== "subagent") + await expect(group.locator('[data-component="text-shimmer"][data-active="true"]')).toBeVisible() + await expect(working).toBeVisible() + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await expect(working).toBeVisible() + }) +} + +for (const grouped of [false, true]) { + test(`uses ${grouped ? "grouped" : "standalone"} background shell presentation for Working`, async ({ page }) => { + await setupTimeline(page, { + settings: { shellToolPartsExpanded: !grouped }, + messages: [ + userMessage(), + assistantMessage( + [ + toolPart("prt_background_previous", "shell", "completed", { command: "echo ready" }), + toolPart( + "prt_background_active", + "shell", + "completed", + { command: "sleep 10" }, + { + metadata: { shellID: "sh_working_background", status: "running" }, + }, + ), + ], + { completed: false }, + ), + ], + }) + const working = page.locator('[data-component="session-working"]') + const group = page.locator('[data-component="collapsed-tool-group"]') + if (!grouped) { + await expect(page.locator('[data-timeline-part-id="prt_background_active"]')).toBeVisible() + await expect(working).toHaveCount(0) + return + } + const trigger = group.getByRole("button", { name: "Used 2 Shell", exact: true, includeHidden: true }) + await expect(trigger).toHaveAttribute("aria-expanded", "false") + await expect(working).toBeVisible() + await trigger.click() + await expect(trigger).toHaveAttribute("aria-expanded", "true") + await expect(working).toBeVisible() + }) +} + +test("replaces Working with Retry and restores it on recovery", async ({ page }) => { + const assistant = assistantMessage([], { completed: false }) + const timeline = await setupTimeline(page, { messages: [userMessage(), assistant] }) + const working = page.locator('[data-component="session-working"]') + await expect(working).toBeVisible() + + await timeline.send(status("retry")) + const retry = page.locator('[data-timeline-row="Retry"]') + await expect(retry).toContainText("Rate limited") + await expect(working).toHaveCount(0) + + await timeline.send(stepStarted(assistant)) + await expect(retry).toHaveCount(0) + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working") + await expect(working).toBeVisible() +}) + +test("hides Working while assistant text streams", async ({ page }) => { + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([], { completed: false })], + }) + const working = page.locator('[data-component="session-working"]') + await expect(working).toBeVisible() + + await timeline.send({ + id: "evt_working_text_started", + type: "session.text.started", + created: 1700000002000, + location: { directory }, + durable: { aggregateID: sessionID, seq: 0, version: 1 }, + data: { sessionID, assistantMessageID: assistantID, ordinal: 0 }, + }) + await timeline.send({ + id: "evt_working_text_delta", + type: "session.text.delta", + created: 1700000002001, + location: { directory }, + data: { sessionID, assistantMessageID: assistantID, ordinal: 0, delta: "The response is streaming." }, + }) + await expect(page.locator(`[data-timeline-part-id="${assistantID}:text:0"]`)).toContainText( + "The response is streaming.", + ) + await expect(working).toHaveCount(0) +}) + +for (const failed of [false, true]) { + test(`shows Working before prompt admission or execution events${failed ? " after an error" : ""}`, async ({ + page, + }) => { + await setupTimeline(page, { + messages: [ + userMessage(), + ...(failed + ? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }] + : []), + ], + }) + const working = page.locator('[data-component="session-working"]') + await expect(working).toHaveCount(0) + + const release = Promise.withResolvers() + await page.route(`**/api/session/${sessionID}/prompt`, async (route) => { + if (route.request().method() !== "POST") return route.fallback() + await release.promise + return route.fallback() + }) + const editor = page.locator('[data-component="composer"]').getByRole("textbox") + await expect(editor).toBeEditable() + await editor.fill("Check the working indicator immediately.") + const requested = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`, + ) + try { + await editor.press("Enter") + const request = await requested + expect(request.postDataJSON()).toMatchObject({ text: "Check the working indicator immediately." }) + await expect(working).toHaveRole("status") + await expect(working.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", "Working") + await expect(working).toBeInViewport() + await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0) + } finally { + release.resolve() + } + }) +} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 0f2a43be0cc..545feb61b49 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -694,6 +694,7 @@ export const dict = { "session.queue.remove": "Remove", "session.queue.reorder": "Reorder queued prompt", "session.queue.attachments": "+ attachments", + "session.timeline.working": "Working", "session.timeline.notice.finished": "{{actor}} finished", "session.timeline.notice.failed": "{{actor}} failed", "session.timeline.notice.cancelled": "{{actor}} cancelled", diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 83e5566ec5b..5a9248632c9 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -540,32 +540,90 @@ function MessageTimelineView( }) const [backgroundHintRef, setBackgroundHintRef] = createSignal() const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null) + const showWorking = createMemo(() => { + const id = sessionID() + if (!id || sessionStatus().type !== "busy") return false + if (data.session.permission.list(id)?.length || data.session.form.list(id)?.length) return false + const active = projection.activeMessageID() + if (!active) return false + const assistant = projection.assistantMessagesByParent().get(active)?.at(-1) + if (assistant?.retry) return false + // Pending steers still project under the previous response until delivery. + // Its error must not hide feedback for a newly submitted prompt. + if ( + assistant?.error && + !data.session.pending.list(id).some((item) => item.type === "user" && item.delivery === "steer") + ) + return false + const content = assistant?.content.at(-1) + if ( + assistant?.time.completed === undefined && + assistant?.time.streamed === undefined && + content?.type === "text" && + content.text.trim() + ) + return false + const background = new Set(props.background.tasks().map((task) => task.id)) + return !projection.rows().some((row) => { + if (row.userMessageID !== active) return false + if (row._tag === "Thinking") return true + if (row._tag === "Notice") { + const message = messageByID().get(row.messageID) + return message?.type === "compaction" && message.status === "running" + } + // Used groups keep the fallback regardless of disclosure state. + if (row._tag !== "AssistantPart" || row.group.type === "context") return false + return (row.group.type === "part" ? [row.group.ref] : row.group.refs).some((ref) => { + const content = Timeline.resolveContent(messageByID().get(ref.messageID), ref.partID) + if (content?.type !== "tool") return false + if (content.state.status === "streaming" || content.state.status === "running") return true + const taskID = content.state.metadata?.[content.name === "subagent" ? "sessionID" : "shellID"] + return background.has(content.id) || (typeof taskID === "string" && background.has(taskID)) + }) + }) + }) return ( -
+ <> +
- +
+ +
-
- + + +
+
+ +
+
+
+ } deferred={(row) => { if (row._tag !== "AssistantPart" || row.group.type !== "part") return false