fix(app): stale keyed show errors

This commit is contained in:
Adam 2026-03-06 11:03:32 -06:00
parent e9568999c3
commit a71b11caca
No known key found for this signature in database
GPG key ID: 9CB48779AF150E75
4 changed files with 315 additions and 249 deletions

View file

@ -445,6 +445,57 @@ export async function seedSessionPermission(
return { id: result.id } return { id: result.id }
} }
export async function seedSessionTask(
sdk: ReturnType<typeof createSdk>,
input: {
sessionID: string
description: string
prompt: string
subagentType?: string
},
) {
const text = [
"Your only valid response is one task tool call.",
`Use this JSON input: ${JSON.stringify({
description: input.description,
prompt: input.prompt,
subagent_type: input.subagentType ?? "general",
})}`,
"Do not output plain text.",
"Wait for the task to start and return the child session id.",
].join("\n")
const result = await seed({
sdk,
sessionID: input.sessionID,
prompt: text,
timeout: 90_000,
probe: async () => {
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
const part = messages
.flatMap((message) => message.parts)
.find((part) => {
if (part.type !== "tool" || part.tool !== "task") return false
if (part.state.input?.description !== input.description) return false
return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0
})
if (!part) return
const id = part.state.metadata?.sessionId
if (typeof id !== "string" || !id) return
const child = await sdk.session
.get({ sessionID: id })
.then((x) => x.data)
.catch(() => undefined)
if (!child?.id) return
return { sessionID: id }
},
})
if (!result) throw new Error("Timed out seeding task tool")
return result
}
export async function seedSessionTodos( export async function seedSessionTodos(
sdk: ReturnType<typeof createSdk>, sdk: ReturnType<typeof createSdk>,
input: { input: {

View file

@ -0,0 +1,37 @@
import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const errs: string[] = []
const onError = (err: Error) => {
errs.push(err.message)
}
page.on("pageerror", onError)
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
try {
await gotoSession(session.id)
const link = page
.locator("a.subagent-link")
.filter({ hasText: /open child session/i })
.first()
await expect(link).toBeVisible({ timeout: 30_000 })
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
})
})

View file

@ -527,19 +527,15 @@ export function AssistantParts(props: {
return ( return (
<Show when={message()}> <Show when={message()}>
{(msg) => ( <Show when={part()}>
<Show when={part()}> <Part
{(p) => ( part={part()!}
<Part message={message()!}
part={p()} showAssistantCopyPartID={props.showAssistantCopyPartID}
message={msg()} turnDurationMs={props.turnDurationMs}
showAssistantCopyPartID={props.showAssistantCopyPartID} defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
turnDurationMs={props.turnDurationMs} />
defaultOpen={partDefaultOpen(p(), props.shellToolDefaultOpen, props.editToolDefaultOpen)} </Show>
/>
)}
</Show>
)}
</Show> </Show>
) )
})()} })()}
@ -741,13 +737,11 @@ export function AssistantMessageDisplay(props: {
return ( return (
<Show when={part()}> <Show when={part()}>
{(p) => ( <Part
<Part part={part()!}
part={p()} message={props.message}
message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID}
showAssistantCopyPartID={props.showAssistantCopyPartID} />
/>
)}
</Show> </Show>
) )
})()} })()}
@ -1410,11 +1404,9 @@ ToolRegistry.register({
trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }}
> >
<Show when={props.output}> <Show when={props.output}>
{(output) => ( <div data-component="tool-output" data-scrollable>
<div data-component="tool-output" data-scrollable> <Markdown text={props.output!} />
<Markdown text={output()} /> </div>
</div>
)}
</Show> </Show>
</BasicTool> </BasicTool>
) )
@ -1436,11 +1428,9 @@ ToolRegistry.register({
}} }}
> >
<Show when={props.output}> <Show when={props.output}>
{(output) => ( <div data-component="tool-output" data-scrollable>
<div data-component="tool-output" data-scrollable> <Markdown text={props.output!} />
<Markdown text={output()} /> </div>
</div>
)}
</Show> </Show>
</BasicTool> </BasicTool>
) )
@ -1465,11 +1455,9 @@ ToolRegistry.register({
}} }}
> >
<Show when={props.output}> <Show when={props.output}>
{(output) => ( <div data-component="tool-output" data-scrollable>
<div data-component="tool-output" data-scrollable> <Markdown text={props.output!} />
<Markdown text={output()} /> </div>
</div>
)}
</Show> </Show>
</BasicTool> </BasicTool>
) )
@ -1613,16 +1601,14 @@ ToolRegistry.register({
<Show when={description()}> <Show when={description()}>
<Switch> <Switch>
<Match when={href()}> <Match when={href()}>
{(url) => ( <a
<a data-slot="basic-tool-tool-subtitle"
data-slot="basic-tool-tool-subtitle" class="clickable subagent-link"
class="clickable subagent-link" href={href()!}
href={url()} onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} >
> {description()}
{description()} </a>
</a>
)}
</Match> </Match>
<Match when={true}> <Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{description()}</span> <span data-slot="basic-tool-tool-subtitle">{description()}</span>
@ -1747,7 +1733,9 @@ ToolRegistry.register({
<ToolFileAccordion <ToolFileAccordion
path={path()} path={path()}
actions={ actions={
<Show when={!pending() && props.metadata.filediff}>{(diff) => <DiffChanges changes={diff()} />}</Show> <Show when={!pending() && props.metadata.filediff}>
<DiffChanges changes={props.metadata.filediff!} />
</Show>
} }
> >
<div data-component="edit-content"> <div data-component="edit-content">
@ -1974,74 +1962,72 @@ ToolRegistry.register({
</div> </div>
} }
> >
{(file) => ( <div data-component="apply-patch-tool">
<div data-component="apply-patch-tool"> <BasicTool
<BasicTool {...props}
{...props} icon="code-lines"
icon="code-lines" defer
defer trigger={
trigger={ <div data-component="edit-trigger">
<div data-component="edit-trigger"> <div data-slot="message-part-title-area">
<div data-slot="message-part-title-area"> <div data-slot="message-part-title">
<div data-slot="message-part-title"> <span data-slot="message-part-title-text">
<span data-slot="message-part-title-text"> <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> </span>
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>
</Show>
</div>
<Show when={!pending() && file().relativePath.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(file().relativePath)}</span>
</div>
</Show>
</div>
<div data-slot="message-part-actions">
<Show when={!pending()}> <Show when={!pending()}>
<DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} /> <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span>
</Show> </Show>
</div> </div>
<Show when={!pending() && single()!.relativePath.includes("/")}>
<div data-slot="message-part-path">
<span data-slot="message-part-directory">{getDirectory(single()!.relativePath)}</span>
</div>
</Show>
</div> </div>
<div data-slot="message-part-actions">
<Show when={!pending()}>
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
</Show>
</div>
</div>
}
>
<ToolFileAccordion
path={single()!.relativePath}
actions={
<Switch>
<Match when={single()!.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={single()!.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={single()!.type === "move"}>
<span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: single()!.additions, deletions: single()!.deletions }} />
</Match>
</Switch>
} }
> >
<ToolFileAccordion <div data-component="apply-patch-file-diff">
path={file().relativePath} <Dynamic
actions={ component={fileComponent}
<Switch> mode="diff"
<Match when={file().type === "add"}> before={{ name: single()!.filePath, contents: single()!.before }}
<span data-slot="apply-patch-change" data-type="added"> after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }}
{i18n.t("ui.patch.action.created")} />
</span> </div>
</Match> </ToolFileAccordion>
<Match when={file().type === "delete"}> </BasicTool>
<span data-slot="apply-patch-change" data-type="removed"> </div>
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={file().type === "move"}>
<span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} />
</Match>
</Switch>
}
>
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: file().filePath, contents: file().before }}
after={{ name: file().movePath ?? file().filePath, contents: file().after }}
/>
</div>
</ToolFileAccordion>
</BasicTool>
</div>
)}
</Show> </Show>
) )
}, },

View file

@ -388,157 +388,149 @@ export function SessionTurn(
> >
<div onClick={autoScroll.handleInteraction}> <div onClick={autoScroll.handleInteraction}>
<Show when={message()}> <Show when={message()}>
{(msg) => ( <div
<div ref={autoScroll.contentRef}
ref={autoScroll.contentRef} data-message={message()!.id}
data-message={msg().id} data-slot="session-turn-message-container"
data-slot="session-turn-message-container" class={props.classes?.container}
class={props.classes?.container} >
> <div data-slot="session-turn-message-content" aria-live="off">
<div data-slot="session-turn-message-content" aria-live="off"> <Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} />
<Message message={msg()} parts={parts()} interrupted={interrupted()} queued={queued()} /> </div>
<Show when={compaction()}>
<div data-slot="session-turn-compaction">
<Part part={compaction()!} message={message()!} hideDetails />
</div> </div>
<Show when={compaction()}> </Show>
{(part) => ( <Show when={assistantMessages().length > 0}>
<div data-slot="session-turn-compaction"> <div data-slot="session-turn-assistant-content" aria-hidden={working()}>
<Part part={part()} message={msg()} hideDetails /> <AssistantParts
</div> messages={assistantMessages()}
)} showAssistantCopyPartID={assistantCopyPartID()}
</Show> turnDurationMs={turnDurationMs()}
<Show when={assistantMessages().length > 0}> working={working()}
<div data-slot="session-turn-assistant-content" aria-hidden={working()}> showReasoningSummaries={showReasoningSummaries()}
<AssistantParts shellToolDefaultOpen={props.shellToolDefaultOpen}
messages={assistantMessages()} editToolDefaultOpen={props.editToolDefaultOpen}
showAssistantCopyPartID={assistantCopyPartID()} />
turnDurationMs={turnDurationMs()} </div>
working={working()} </Show>
showReasoningSummaries={showReasoningSummaries()} <Show when={showThinking()}>
shellToolDefaultOpen={props.shellToolDefaultOpen} <div data-slot="session-turn-thinking">
editToolDefaultOpen={props.editToolDefaultOpen} <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!showReasoningSummaries()}>
<TextReveal
text={reasoningHeading()}
class="session-turn-thinking-heading"
travel={25}
duration={700}
/> />
</div> </Show>
</Show> </div>
<Show when={showThinking()}> </Show>
<div data-slot="session-turn-thinking"> <SessionRetry status={status()} show={active()} />
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} /> <Show when={edited() > 0 && !working()}>
<Show when={!showReasoningSummaries()}> <div data-slot="session-turn-diffs">
<TextReveal <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
text={reasoningHeading()} <Collapsible.Trigger>
class="session-turn-thinking-heading" <div data-component="session-turn-diffs-trigger">
travel={25} <div data-slot="session-turn-diffs-title">
duration={700} <span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
/> <span data-slot="session-turn-diffs-count">
</Show> {edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</div> </span>
</Show> <div data-slot="session-turn-diffs-meta">
<SessionRetry status={status()} show={active()} /> <DiffChanges changes={diffs()} variant="bars" />
<Show when={edited() > 0 && !working()}> <Collapsible.Arrow />
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">
{i18n.t("ui.sessionReview.change.modified")}
</span>
<span data-slot="session-turn-diffs-count">
{edited()} {i18n.t(edited() === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
<div data-slot="session-turn-diffs-meta">
<DiffChanges changes={diffs()} variant="bars" />
<Collapsible.Arrow />
</div>
</div> </div>
</div> </div>
</Collapsible.Trigger> </div>
<Collapsible.Content> </Collapsible.Trigger>
<Show when={open()}> <Collapsible.Content>
<div data-component="session-turn-diffs-content"> <Show when={open()}>
<Accordion <div data-component="session-turn-diffs-content">
multiple <Accordion
style={{ "--sticky-accordion-offset": "40px" }} multiple
value={expanded()} style={{ "--sticky-accordion-offset": "40px" }}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} value={expanded()}
> onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
<For each={diffs()}> >
{(diff) => { <For each={diffs()}>
const active = createMemo(() => expanded().includes(diff.file)) {(diff) => {
const [visible, setVisible] = createSignal(false) const active = createMemo(() => expanded().includes(diff.file))
const [visible, setVisible] = createSignal(false)
createEffect( createEffect(
on( on(
active, active,
(value) => { (value) => {
if (!value) { if (!value) {
setVisible(false) setVisible(false)
return return
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!active()) return if (!active()) return
setVisible(true) setVisible(true)
}) })
}, },
{ defer: true }, { defer: true },
), ),
) )
return ( return (
<Accordion.Item value={diff.file}> <Accordion.Item value={diff.file}>
<StickyAccordionHeader> <StickyAccordionHeader>
<Accordion.Trigger> <Accordion.Trigger>
<div data-slot="session-turn-diff-trigger"> <div data-slot="session-turn-diff-trigger">
<span data-slot="session-turn-diff-path"> <span data-slot="session-turn-diff-path">
<Show when={diff.file.includes("/")}> <Show when={diff.file.includes("/")}>
<span data-slot="session-turn-diff-directory"> <span data-slot="session-turn-diff-directory">
{`\u202A${getDirectory(diff.file)}\u202C`} {`\u202A${getDirectory(diff.file)}\u202C`}
</span>
</Show>
<span data-slot="session-turn-diff-filename">
{getFilename(diff.file)}
</span> </span>
</Show>
<span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
</span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span> </span>
<div data-slot="session-turn-diff-meta">
<span data-slot="session-turn-diff-changes">
<DiffChanges changes={diff} />
</span>
<span data-slot="session-turn-diff-chevron">
<Icon name="chevron-down" size="small" />
</span>
</div>
</div> </div>
</Accordion.Trigger> </div>
</StickyAccordionHeader> </Accordion.Trigger>
<Accordion.Content> </StickyAccordionHeader>
<Show when={visible()}> <Accordion.Content>
<div data-slot="session-turn-diff-view" data-scrollable> <Show when={visible()}>
<Dynamic <div data-slot="session-turn-diff-view" data-scrollable>
component={fileComponent} <Dynamic
mode="diff" component={fileComponent}
before={{ name: diff.file, contents: diff.before }} mode="diff"
after={{ name: diff.file, contents: diff.after }} before={{ name: diff.file, contents: diff.before }}
/> after={{ name: diff.file, contents: diff.after }}
</div> />
</Show> </div>
</Accordion.Content> </Show>
</Accordion.Item> </Accordion.Content>
) </Accordion.Item>
}} )
</For> }}
</Accordion> </For>
</div> </Accordion>
</Show> </div>
</Collapsible.Content> </Show>
</Collapsible> </Collapsible.Content>
</div> </Collapsible>
</Show> </div>
<Show when={error()}> </Show>
<Card variant="error" class="error-card"> <Show when={error()}>
{errorText()} <Card variant="error" class="error-card">
</Card> {errorText()}
</Show> </Card>
</div> </Show>
)} </div>
</Show> </Show>
{props.children} {props.children}
</div> </div>