Преглед изворни кода

fix(app): stale keyed show errors

Adam пре 1 месец
родитељ
комит
a71b11caca

+ 51 - 0
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: {

+ 37 - 0
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)
+    }
+  })
+})

+ 94 - 108
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>
     )
   },

+ 136 - 144
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>