summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-06 11:03:32 -0600
committerAdam <[email protected]>2026-03-06 11:03:37 -0600
commita71b11caca88243a9e4399317bcc5234d432976c (patch)
tree3854bfd906ddaae04351d8e327dc5d6e6fc9d536
parente9568999c385242756c2ea3530560481cf97999d (diff)
downloadopencode-a71b11caca88243a9e4399317bcc5234d432976c.tar.gz
opencode-a71b11caca88243a9e4399317bcc5234d432976c.zip
fix(app): stale keyed show errors
-rw-r--r--packages/app/e2e/actions.ts51
-rw-r--r--packages/app/e2e/session/session-child-navigation.spec.ts37
-rw-r--r--packages/ui/src/components/message-part.tsx202
-rw-r--r--packages/ui/src/components/session-turn.tsx280
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>