diff options
| author | Adam <[email protected]> | 2026-03-06 11:03:32 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-03-06 11:03:37 -0600 |
| commit | a71b11caca88243a9e4399317bcc5234d432976c (patch) | |
| tree | 3854bfd906ddaae04351d8e327dc5d6e6fc9d536 | |
| parent | e9568999c385242756c2ea3530560481cf97999d (diff) | |
| download | opencode-a71b11caca88243a9e4399317bcc5234d432976c.tar.gz opencode-a71b11caca88243a9e4399317bcc5234d432976c.zip | |
fix(app): stale keyed show errors
| -rw-r--r-- | packages/app/e2e/actions.ts | 51 | ||||
| -rw-r--r-- | packages/app/e2e/session/session-child-navigation.spec.ts | 37 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 202 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 280 |
4 files changed, 318 insertions, 252 deletions
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index fbb13008b..919a1add8 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -445,6 +445,57 @@ export async function seedSessionPermission( 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( sdk: ReturnType<typeof createSdk>, input: { diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts new file mode 100644 index 000000000..ac2dca33c --- /dev/null +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -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) + } + }) +}) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index f59b17e9a..9286d2a92 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -527,19 +527,15 @@ export function AssistantParts(props: { return ( <Show when={message()}> - {(msg) => ( - <Show when={part()}> - {(p) => ( - <Part - part={p()} - message={msg()} - showAssistantCopyPartID={props.showAssistantCopyPartID} - turnDurationMs={props.turnDurationMs} - defaultOpen={partDefaultOpen(p(), props.shellToolDefaultOpen, props.editToolDefaultOpen)} - /> - )} - </Show> - )} + <Show when={part()}> + <Part + part={part()!} + message={message()!} + showAssistantCopyPartID={props.showAssistantCopyPartID} + turnDurationMs={props.turnDurationMs} + defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)} + /> + </Show> </Show> ) })()} @@ -741,13 +737,11 @@ export function AssistantMessageDisplay(props: { return ( <Show when={part()}> - {(p) => ( - <Part - part={p()} - message={props.message} - showAssistantCopyPartID={props.showAssistantCopyPartID} - /> - )} + <Part + part={part()!} + message={props.message} + showAssistantCopyPartID={props.showAssistantCopyPartID} + /> </Show> ) })()} @@ -1410,11 +1404,9 @@ ToolRegistry.register({ trigger={{ title: i18n.t("ui.tool.list"), subtitle: getDirectory(props.input.path || "/") }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> </BasicTool> ) @@ -1436,11 +1428,9 @@ ToolRegistry.register({ }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> </BasicTool> ) @@ -1465,11 +1455,9 @@ ToolRegistry.register({ }} > <Show when={props.output}> - {(output) => ( - <div data-component="tool-output" data-scrollable> - <Markdown text={output()} /> - </div> - )} + <div data-component="tool-output" data-scrollable> + <Markdown text={props.output!} /> + </div> </Show> </BasicTool> ) @@ -1613,16 +1601,14 @@ ToolRegistry.register({ <Show when={description()}> <Switch> <Match when={href()}> - {(url) => ( - <a - data-slot="basic-tool-tool-subtitle" - class="clickable subagent-link" - href={url()} - onClick={(e) => e.stopPropagation()} - > - {description()} - </a> - )} + <a + data-slot="basic-tool-tool-subtitle" + class="clickable subagent-link" + href={href()!} + onClick={(e) => e.stopPropagation()} + > + {description()} + </a> </Match> <Match when={true}> <span data-slot="basic-tool-tool-subtitle">{description()}</span> @@ -1747,7 +1733,9 @@ ToolRegistry.register({ <ToolFileAccordion path={path()} 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"> @@ -1974,74 +1962,72 @@ ToolRegistry.register({ </div> } > - {(file) => ( - <div data-component="apply-patch-tool"> - <BasicTool - {...props} - icon="code-lines" - defer - trigger={ - <div data-component="edit-trigger"> - <div data-slot="message-part-title-area"> - <div data-slot="message-part-title"> - <span data-slot="message-part-title-text"> - <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> - </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"> + <div data-component="apply-patch-tool"> + <BasicTool + {...props} + icon="code-lines" + defer + trigger={ + <div data-component="edit-trigger"> + <div data-slot="message-part-title-area"> + <div data-slot="message-part-title"> + <span data-slot="message-part-title-text"> + <TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} /> + </span> <Show when={!pending()}> - <DiffChanges changes={{ additions: file().additions, deletions: file().deletions }} /> + <span data-slot="message-part-title-filename">{getFilename(single()!.relativePath)}</span> </Show> </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 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 - path={file().relativePath} - actions={ - <Switch> - <Match when={file().type === "add"}> - <span data-slot="apply-patch-change" data-type="added"> - {i18n.t("ui.patch.action.created")} - </span> - </Match> - <Match when={file().type === "delete"}> - <span data-slot="apply-patch-change" data-type="removed"> - {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> - )} + <div data-component="apply-patch-file-diff"> + <Dynamic + component={fileComponent} + mode="diff" + before={{ name: single()!.filePath, contents: single()!.before }} + after={{ name: single()!.movePath ?? single()!.filePath, contents: single()!.after }} + /> + </div> + </ToolFileAccordion> + </BasicTool> + </div> </Show> ) }, diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index a8a41b8ef..3323a9fc6 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -388,157 +388,149 @@ export function SessionTurn( > <div onClick={autoScroll.handleInteraction}> <Show when={message()}> - {(msg) => ( - <div - ref={autoScroll.contentRef} - data-message={msg().id} - data-slot="session-turn-message-container" - class={props.classes?.container} - > - <div data-slot="session-turn-message-content" aria-live="off"> - <Message message={msg()} parts={parts()} interrupted={interrupted()} queued={queued()} /> + <div + ref={autoScroll.contentRef} + data-message={message()!.id} + data-slot="session-turn-message-container" + class={props.classes?.container} + > + <div data-slot="session-turn-message-content" aria-live="off"> + <Message message={message()!} parts={parts()} interrupted={interrupted()} queued={queued()} /> + </div> + <Show when={compaction()}> + <div data-slot="session-turn-compaction"> + <Part part={compaction()!} message={message()!} hideDetails /> + </div> + </Show> + <Show when={assistantMessages().length > 0}> + <div data-slot="session-turn-assistant-content" aria-hidden={working()}> + <AssistantParts + messages={assistantMessages()} + showAssistantCopyPartID={assistantCopyPartID()} + turnDurationMs={turnDurationMs()} + working={working()} + showReasoningSummaries={showReasoningSummaries()} + shellToolDefaultOpen={props.shellToolDefaultOpen} + editToolDefaultOpen={props.editToolDefaultOpen} + /> </div> - <Show when={compaction()}> - {(part) => ( - <div data-slot="session-turn-compaction"> - <Part part={part()} message={msg()} hideDetails /> - </div> - )} - </Show> - <Show when={assistantMessages().length > 0}> - <div data-slot="session-turn-assistant-content" aria-hidden={working()}> - <AssistantParts - messages={assistantMessages()} - showAssistantCopyPartID={assistantCopyPartID()} - turnDurationMs={turnDurationMs()} - working={working()} - showReasoningSummaries={showReasoningSummaries()} - shellToolDefaultOpen={props.shellToolDefaultOpen} - editToolDefaultOpen={props.editToolDefaultOpen} + </Show> + <Show when={showThinking()}> + <div data-slot="session-turn-thinking"> + <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 when={showThinking()}> - <div data-slot="session-turn-thinking"> - <TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} /> - <Show when={!showReasoningSummaries()}> - <TextReveal - text={reasoningHeading()} - class="session-turn-thinking-heading" - travel={25} - duration={700} - /> - </Show> - </div> - </Show> - <SessionRetry status={status()} show={active()} /> - <Show when={edited() > 0 && !working()}> - <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> + </Show> + </div> + </Show> + <SessionRetry status={status()} show={active()} /> + <Show when={edited() > 0 && !working()}> + <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> - </Collapsible.Trigger> - <Collapsible.Content> - <Show when={open()}> - <div data-component="session-turn-diffs-content"> - <Accordion - multiple - style={{ "--sticky-accordion-offset": "40px" }} - value={expanded()} - onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} - > - <For each={diffs()}> - {(diff) => { - const active = createMemo(() => expanded().includes(diff.file)) - const [visible, setVisible] = createSignal(false) - - createEffect( - on( - active, - (value) => { - if (!value) { - setVisible(false) - return - } - - requestAnimationFrame(() => { - if (!active()) return - setVisible(true) - }) - }, - { defer: true }, - ), - ) - - return ( - <Accordion.Item value={diff.file}> - <StickyAccordionHeader> - <Accordion.Trigger> - <div data-slot="session-turn-diff-trigger"> - <span data-slot="session-turn-diff-path"> - <Show when={diff.file.includes("/")}> - <span data-slot="session-turn-diff-directory"> - {`\u202A${getDirectory(diff.file)}\u202C`} - </span> - </Show> - <span data-slot="session-turn-diff-filename"> - {getFilename(diff.file)} + </div> + </Collapsible.Trigger> + <Collapsible.Content> + <Show when={open()}> + <div data-component="session-turn-diffs-content"> + <Accordion + multiple + style={{ "--sticky-accordion-offset": "40px" }} + value={expanded()} + onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + <For each={diffs()}> + {(diff) => { + const active = createMemo(() => expanded().includes(diff.file)) + const [visible, setVisible] = createSignal(false) + + createEffect( + on( + active, + (value) => { + if (!value) { + setVisible(false) + return + } + + requestAnimationFrame(() => { + if (!active()) return + setVisible(true) + }) + }, + { defer: true }, + ), + ) + + return ( + <Accordion.Item value={diff.file}> + <StickyAccordionHeader> + <Accordion.Trigger> + <div data-slot="session-turn-diff-trigger"> + <span data-slot="session-turn-diff-path"> + <Show when={diff.file.includes("/")}> + <span data-slot="session-turn-diff-directory"> + {`\u202A${getDirectory(diff.file)}\u202C`} </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> - <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> - </Accordion.Trigger> - </StickyAccordionHeader> - <Accordion.Content> - <Show when={visible()}> - <div data-slot="session-turn-diff-view" data-scrollable> - <Dynamic - component={fileComponent} - mode="diff" - before={{ name: diff.file, contents: diff.before }} - after={{ name: diff.file, contents: diff.after }} - /> </div> - </Show> - </Accordion.Content> - </Accordion.Item> - ) - }} - </For> - </Accordion> - </div> - </Show> - </Collapsible.Content> - </Collapsible> - </div> - </Show> - <Show when={error()}> - <Card variant="error" class="error-card"> - {errorText()} - </Card> - </Show> - </div> - )} + </div> + </Accordion.Trigger> + </StickyAccordionHeader> + <Accordion.Content> + <Show when={visible()}> + <div data-slot="session-turn-diff-view" data-scrollable> + <Dynamic + component={fileComponent} + mode="diff" + before={{ name: diff.file, contents: diff.before }} + after={{ name: diff.file, contents: diff.after }} + /> + </div> + </Show> + </Accordion.Content> + </Accordion.Item> + ) + }} + </For> + </Accordion> + </div> + </Show> + </Collapsible.Content> + </Collapsible> + </div> + </Show> + <Show when={error()}> + <Card variant="error" class="error-card"> + {errorText()} + </Card> + </Show> + </div> </Show> {props.children} </div> |
