feat(app): show working when timeline progress is hidden (#46711)

This commit is contained in:
Luke Parker 2026-09-02 12:06:32 +10:00 committed by GitHub
parent 46c33630b7
commit d117a33982
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 354 additions and 17 deletions

View file

@ -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<void>()
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()
}
})
}

View file

@ -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",

View file

@ -540,32 +540,90 @@ function MessageTimelineView(
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
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 (
<VirtualizedTimeline
workspaceSession={workspaceSession}
bottomSpacer={
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<>
<Show when={showWorking()}>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
data-component="session-working"
role="status"
classList={{
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<BackgroundMoveHint />
<div class={`flex h-9 items-start pt-3 text-[13px] font-[530] leading-text-compact ${turnPadding()}`}>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</div>
</div>
</Show>
</Show>
<Show when={backgroundHintPresence.present()}>
<div
data-component="session-background-hint-row"
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-[1000px] md:mx-auto": props.centered,
}}
>
<div
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex items-start ${showWorking() ? "h-6" : "h-9 pt-3"} ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />
</div>
</div>
</Show>
</>
}
deferred={(row) => {
if (row._tag !== "AssistantPart" || row.group.type !== "part") return false