Просмотр исходного кода

fix(app): render code mode executions (#42949)

Luke Parker 1 день назад
Родитель
Сommit
c400746dd5

+ 112 - 42
packages/session-ui/src/components/message-part.tsx

@@ -547,6 +547,12 @@ export function getToolInfo(
         title: i18n.t("ui.tool.shell"),
         subtitle: input.command,
       }
+    case "execute":
+      return {
+        icon: "console",
+        title: i18n.t("ui.tool.execute"),
+        subtitle: input.code,
+      }
     case "edit":
       return {
         icon: "code-lines",
@@ -1574,6 +1580,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
     if (typeof value === "string" && value) return value
     return taskId()
   })
+  const toolError = createMemo(() => partError(part(), i18n.t("ui.toolErrorCard.failed")))
 
   const render = createMemo(() => ToolRegistry.render(part().tool) ?? GenericTool)
   const controlledOpen = () => (props.onToolOpenChange ? (props.toolOpen ?? props.defaultOpen) : undefined)
@@ -1583,7 +1590,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
     <Show when={!hideQuestion()}>
       <div data-component="tool-part-wrapper" data-timeline-part-id={part().id}>
         <Switch>
-          <Match when={part().state.status === "error" && (part().state as any).error}>
+          <Match when={toolError()}>
             {(error) => {
               const cleaned = error().replace("Error: ", "")
               if (part().tool === "question" && cleaned.includes("dismissed this question")) {
@@ -1644,6 +1651,26 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
   )
 }
 
+function partError(part: ToolPart, fallback: string) {
+  if (part.state.status === "error") return part.state.error
+  if (part.tool !== "execute" || !("metadata" in part.state)) return undefined
+  const calls = part.state.metadata?.toolCalls
+  const failed =
+    part.state.metadata?.error === true ||
+    (Array.isArray(calls) &&
+      calls.some(
+        (call) =>
+          call !== null &&
+          typeof call === "object" &&
+          !Array.isArray(call) &&
+          "status" in call &&
+          call.status === "error",
+      ))
+  if (!failed) return undefined
+  if ("output" in part.state && typeof part.state.output === "string" && part.state.output) return part.state.output
+  return fallback
+}
+
 export function MessageDivider(props: { label: string }) {
   return (
     <div data-component="compaction-part">
@@ -2104,6 +2131,84 @@ ToolRegistry.register({
 
 ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") })
 
+function ConsoleOutput(props: { copy: string; children: JSX.Element }) {
+  const i18n = useI18n()
+  const [copied, setCopied] = createSignal(false)
+
+  const copy = async () => {
+    if (!props.copy) return
+    if (!(await writeClipboard(props.copy))) return
+    setCopied(true)
+    setTimeout(() => setCopied(false), 2000)
+  }
+
+  return (
+    <div data-component="bash-output" dir="ltr">
+      <div data-slot="bash-copy">
+        <TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
+          <IconButtonV2
+            icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
+            size="normal"
+            variant="ghost-muted"
+            onMouseDown={(event) => event.preventDefault()}
+            onClick={copy}
+            aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
+          />
+        </TooltipV2>
+      </div>
+      <div
+        data-slot="bash-scroll"
+        data-scrollable
+        tabIndex={0}
+        role="region"
+        aria-label={i18n.t("ui.scrollView.ariaLabel")}
+      >
+        <pre data-slot="bash-pre">
+          <code>{props.children}</code>
+        </pre>
+      </div>
+    </div>
+  )
+}
+
+ToolRegistry.register({
+  name: "execute",
+  render(props) {
+    const i18n = useI18n()
+    const pending = () => props.status === "pending" || props.status === "streaming" || props.status === "running"
+    const code = createMemo(() => (typeof props.input.code === "string" ? props.input.code : ""))
+    const text = createMemo(() => {
+      const output = stripAnsi(props.output ?? "").replace(/\r\n?/g, "\n")
+      return `${code()}${output ? "\n\n" + output : ""}`
+    })
+    const sawPending = pending()
+    return (
+      <BasicTool
+        {...props}
+        icon="console"
+        allowOpenWhilePending
+        trigger={(open) => (
+          <div data-slot="basic-tool-tool-info-structured">
+            <span data-slot="basic-tool-tool-indicator">
+              <Icon name="console" size="small" />
+            </span>
+            <div data-slot="basic-tool-tool-info-main">
+              <span data-slot="basic-tool-tool-title">
+                <TextShimmer text={i18n.t("ui.tool.execute")} active={pending()} />
+              </span>
+              <Show when={!open() && code()}>
+                <ShellSubmessage text={code()} animate={sawPending} />
+              </Show>
+            </div>
+          </div>
+        )}
+      >
+        <ConsoleOutput copy={text()}>{text()}</ConsoleOutput>
+      </BasicTool>
+    )
+  },
+})
+
 ToolRegistry.register({
   name: "shell",
   render(props) {
@@ -2116,17 +2221,6 @@ ToolRegistry.register({
       const out = stripAnsi(props.output || props.metadata.output || "").replace(/\r\n?/g, "\n")
       return `${command()}${out ? "\n\n" + out : ""}`
     })
-    const [copied, setCopied] = createSignal(false)
-
-    const handleCopy = async () => {
-      const content = command()
-      if (!content) return
-      if (await writeClipboard(content)) {
-        setCopied(true)
-        setTimeout(() => setCopied(false), 2000)
-      }
-    }
-
     return (
       <BasicTool
         {...props}
@@ -2145,36 +2239,12 @@ ToolRegistry.register({
           </div>
         )}
       >
-        <div data-component="bash-output" dir="ltr">
-          <div data-slot="bash-copy">
-            <TooltipV2 value={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")} placement="top">
-              <IconButtonV2
-                icon={<IconV2 name={copied() ? "check" : "outline-copy"} size="small" />}
-                size="normal"
-                variant="ghost-muted"
-                onMouseDown={(e) => e.preventDefault()}
-                onClick={handleCopy}
-                aria-label={copied() ? i18n.t("ui.message.copied") : i18n.t("ui.message.copy")}
-              />
-            </TooltipV2>
-          </div>
-          <div
-            data-slot="bash-scroll"
-            data-scrollable
-            tabIndex={0}
-            role="region"
-            aria-label={i18n.t("ui.scrollView.ariaLabel")}
-          >
-            <pre data-slot="bash-pre">
-              <code>
-                <span data-slot="bash-prompt" aria-hidden="true">
-                  {"$ "}
-                </span>
-                {text()}
-              </code>
-            </pre>
-          </div>
-        </div>
+        <ConsoleOutput copy={command()}>
+          <span data-slot="bash-prompt" aria-hidden="true">
+            {"$ "}
+          </span>
+          {text()}
+        </ConsoleOutput>
       </BasicTool>
     )
   },

+ 2 - 1
packages/session-ui/src/components/part-default-open.test.ts

@@ -71,8 +71,9 @@ describe("partDefaultOpen", () => {
     ).toBe(true)
   })
 
-  test("preserves shell defaults", () => {
+  test("applies shell defaults to console tools", () => {
     expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
+    expect(partDefaultOpen(tool("execute", {}), true, false)).toBe(true)
   })
 })
 

+ 1 - 1
packages/session-ui/src/components/part-default-open.ts

@@ -23,7 +23,7 @@ function deletionOnly(part: ToolPart) {
 
 export function partDefaultOpen(part: PartType, shell = false, edit = false): boolean | undefined {
   if (part.type !== "tool") return undefined
-  if (part.tool === "bash" || part.tool === "shell") return shell
+  if (part.tool === "bash" || part.tool === "shell" || part.tool === "execute") return shell
   if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
     if (!edit) return false
     return !deletionOnly(part)

+ 1 - 0
packages/session-ui/src/components/tool-error-card.tsx

@@ -54,6 +54,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
       websearch: "ui.tool.websearch",
       bash: "ui.tool.shell",
       shell: "ui.tool.shell",
+      execute: "ui.tool.execute",
       patch: "ui.tool.patch",
       apply_patch: "ui.tool.patch",
       question: "ui.tool.questions",

+ 1 - 0
packages/ui/src/i18n/en.ts

@@ -158,6 +158,7 @@ const source = {
   "ui.tool.websearch": "Web Search",
   "ui.tool.websearch.provider": "{{provider}} Web Search",
   "ui.tool.shell": "Shell",
+  "ui.tool.execute": "Execute",
   "ui.tool.patch": "Patch",
   "ui.tool.questions": "Questions",
   "ui.tool.questions.numbered": "Questions {{number}}",