Adam 1 miesiąc temu
rodzic
commit
3394402aef

+ 38 - 2
packages/ui/src/components/basic-tool.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
+import { createEffect, createSignal, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
 import { Collapsible } from "./collapsible"
 import { Collapsible } from "./collapsible"
 import type { IconProps } from "./icon"
 import type { IconProps } from "./icon"
 import { TextShimmer } from "./text-shimmer"
 import { TextShimmer } from "./text-shimmer"
@@ -27,18 +27,52 @@ export interface BasicToolProps {
   hideDetails?: boolean
   hideDetails?: boolean
   defaultOpen?: boolean
   defaultOpen?: boolean
   forceOpen?: boolean
   forceOpen?: boolean
+  defer?: boolean
   locked?: boolean
   locked?: boolean
   onSubtitleClick?: () => void
   onSubtitleClick?: () => void
 }
 }
 
 
 export function BasicTool(props: BasicToolProps) {
 export function BasicTool(props: BasicToolProps) {
   const [open, setOpen] = createSignal(props.defaultOpen ?? false)
   const [open, setOpen] = createSignal(props.defaultOpen ?? false)
+  const [ready, setReady] = createSignal(open())
   const pending = () => props.status === "pending" || props.status === "running"
   const pending = () => props.status === "pending" || props.status === "running"
 
 
+  let frame: number | undefined
+
+  const cancel = () => {
+    if (frame === undefined) return
+    cancelAnimationFrame(frame)
+    frame = undefined
+  }
+
+  onCleanup(cancel)
+
   createEffect(() => {
   createEffect(() => {
     if (props.forceOpen) setOpen(true)
     if (props.forceOpen) setOpen(true)
   })
   })
 
 
+  createEffect(
+    on(
+      open,
+      (value) => {
+        if (!props.defer) return
+        if (!value) {
+          cancel()
+          setReady(false)
+          return
+        }
+
+        cancel()
+        frame = requestAnimationFrame(() => {
+          frame = undefined
+          if (!open()) return
+          setReady(true)
+        })
+      },
+      { defer: true },
+    ),
+  )
+
   const handleOpenChange = (value: boolean) => {
   const handleOpenChange = (value: boolean) => {
     if (pending()) return
     if (pending()) return
     if (props.locked && !value) return
     if (props.locked && !value) return
@@ -114,7 +148,9 @@ export function BasicTool(props: BasicToolProps) {
         </div>
         </div>
       </Collapsible.Trigger>
       </Collapsible.Trigger>
       <Show when={props.children && !props.hideDetails}>
       <Show when={props.children && !props.hideDetails}>
-        <Collapsible.Content>{props.children}</Collapsible.Content>
+        <Collapsible.Content>
+          <Show when={!props.defer || ready()}>{props.children}</Show>
+        </Collapsible.Content>
       </Show>
       </Show>
     </Collapsible>
     </Collapsible>
   )
   )

+ 33 - 27
packages/ui/src/components/message-part.css

@@ -326,8 +326,7 @@
 }
 }
 
 
 [data-slot="collapsible-content"]:has([data-component="edit-content"]),
 [data-slot="collapsible-content"]:has([data-component="edit-content"]),
-[data-slot="collapsible-content"]:has([data-component="write-content"]),
-[data-slot="collapsible-content"]:has([data-component="apply-patch-files"]) {
+[data-slot="collapsible-content"]:has([data-component="write-content"]) {
   border: 1px solid var(--border-weak-base);
   border: 1px solid var(--border-weak-base);
   border-radius: 6px;
   border-radius: 6px;
   background: transparent;
   background: transparent;
@@ -1219,21 +1218,31 @@
   }
   }
 }
 }
 
 
-[data-component="apply-patch-files"] {
-  display: flex;
-  flex-direction: column;
-}
+[data-component="accordion"][data-scope="apply-patch"] {
+  [data-slot="apply-patch-trigger-content"] {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    min-width: 0;
+    gap: 12px;
+  }
 
 
-[data-component="apply-patch-file"] {
-  display: flex;
-  flex-direction: column;
+  [data-slot="apply-patch-file-path"] {
+    font-family: var(--font-family-mono);
+    font-size: var(--font-size-small);
+    color: var(--text-weak);
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    flex-grow: 1;
+  }
 
 
-  [data-slot="apply-patch-file-header"] {
-    display: flex;
+  [data-slot="apply-patch-trigger-actions"] {
+    flex-shrink: 0;
+    display: inline-flex;
     align-items: center;
     align-items: center;
-    gap: 8px;
-    padding: 8px 12px;
-    background-color: transparent;
+    gap: 10px;
   }
   }
 
 
   [data-slot="apply-patch-file-action"] {
   [data-slot="apply-patch-file-action"] {
@@ -1257,26 +1266,23 @@
     }
     }
   }
   }
 
 
-  [data-slot="apply-patch-file-path"] {
-    font-family: var(--font-family-mono);
-    font-size: var(--font-size-small);
-    color: var(--text-weak);
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-    flex-grow: 1;
-  }
-
   [data-slot="apply-patch-deletion-count"] {
   [data-slot="apply-patch-deletion-count"] {
     font-family: var(--font-family-mono);
     font-family: var(--font-family-mono);
     font-size: var(--font-size-small);
     font-size: var(--font-size-small);
     color: var(--text-critical-base);
     color: var(--text-critical-base);
     flex-shrink: 0;
     flex-shrink: 0;
   }
   }
-}
 
 
-[data-component="apply-patch-file"] + [data-component="apply-patch-file"] {
-  border-top: 1px solid var(--border-weaker-base);
+  [data-slot="apply-patch-file-chevron"] {
+    display: inline-flex;
+    color: var(--icon-weaker);
+    transform: rotate(-90deg);
+    transition: transform 0.15s ease;
+  }
+
+  [data-slot="accordion-item"][data-expanded] [data-slot="apply-patch-file-chevron"] {
+    transform: rotate(0deg);
+  }
 }
 }
 
 
 [data-component="apply-patch-file-diff"] {
 [data-component="apply-patch-file-diff"] {

+ 88 - 46
packages/ui/src/components/message-part.tsx

@@ -35,6 +35,7 @@ import { useDialog } from "../context/dialog"
 import { useI18n } from "../context/i18n"
 import { useI18n } from "../context/i18n"
 import { BasicTool } from "./basic-tool"
 import { BasicTool } from "./basic-tool"
 import { GenericTool } from "./basic-tool"
 import { GenericTool } from "./basic-tool"
+import { Accordion } from "./accordion"
 import { Button } from "./button"
 import { Button } from "./button"
 import { Card } from "./card"
 import { Card } from "./card"
 import { Collapsible } from "./collapsible"
 import { Collapsible } from "./collapsible"
@@ -1482,6 +1483,7 @@ ToolRegistry.register({
       <BasicTool
       <BasicTool
         {...props}
         {...props}
         icon="code-lines"
         icon="code-lines"
+        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">
@@ -1542,6 +1544,7 @@ ToolRegistry.register({
       <BasicTool
       <BasicTool
         {...props}
         {...props}
         icon="code-lines"
         icon="code-lines"
+        defer
         trigger={
         trigger={
           <div data-component="write-trigger">
           <div data-component="write-trigger">
             <div data-slot="message-part-title-area">
             <div data-slot="message-part-title-area">
@@ -1602,6 +1605,16 @@ ToolRegistry.register({
     const i18n = useI18n()
     const i18n = useI18n()
     const diffComponent = useDiffComponent()
     const diffComponent = useDiffComponent()
     const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
     const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[])
+    const [expanded, setExpanded] = createSignal<string[]>([])
+    let seeded = false
+
+    createEffect(() => {
+      const list = files()
+      if (list.length === 0) return
+      if (seeded) return
+      seeded = true
+      setExpanded(list.filter((f) => f.type !== "delete").map((f) => f.filePath))
+    })
 
 
     const subtitle = createMemo(() => {
     const subtitle = createMemo(() => {
       const count = files().length
       const count = files().length
@@ -1613,60 +1626,89 @@ ToolRegistry.register({
       <BasicTool
       <BasicTool
         {...props}
         {...props}
         icon="code-lines"
         icon="code-lines"
+        defer
         trigger={{
         trigger={{
           title: i18n.t("ui.tool.patch"),
           title: i18n.t("ui.tool.patch"),
           subtitle: subtitle(),
           subtitle: subtitle(),
         }}
         }}
       >
       >
         <Show when={files().length > 0}>
         <Show when={files().length > 0}>
-          <div data-component="apply-patch-files">
+          <Accordion
+            multiple
+            data-scope="apply-patch"
+            value={expanded()}
+            onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
+          >
             <For each={files()}>
             <For each={files()}>
-              {(file) => (
-                <div data-component="apply-patch-file">
-                  <div data-slot="apply-patch-file-header">
-                    <Switch>
-                      <Match when={file.type === "delete"}>
-                        <span data-slot="apply-patch-file-action" data-type="delete">
-                          {i18n.t("ui.patch.action.deleted")}
-                        </span>
-                      </Match>
-                      <Match when={file.type === "add"}>
-                        <span data-slot="apply-patch-file-action" data-type="add">
-                          {i18n.t("ui.patch.action.created")}
-                        </span>
-                      </Match>
-                      <Match when={file.type === "move"}>
-                        <span data-slot="apply-patch-file-action" data-type="move">
-                          {i18n.t("ui.patch.action.moved")}
-                        </span>
-                      </Match>
-                      <Match when={file.type === "update"}>
-                        <span data-slot="apply-patch-file-action" data-type="update">
-                          {i18n.t("ui.patch.action.patched")}
-                        </span>
-                      </Match>
-                    </Switch>
-                    <span data-slot="apply-patch-file-path">{file.relativePath}</span>
-                    <Show when={file.type !== "delete"}>
-                      <DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
-                    </Show>
-                    <Show when={file.type === "delete"}>
-                      <span data-slot="apply-patch-deletion-count">-{file.deletions}</span>
-                    </Show>
-                  </div>
-                  <Show when={file.type !== "delete"}>
-                    <div data-component="apply-patch-file-diff">
-                      <Dynamic
-                        component={diffComponent}
-                        before={{ name: file.filePath, contents: file.before }}
-                        after={{ name: file.filePath, contents: file.after }}
-                      />
-                    </div>
-                  </Show>
-                </div>
-              )}
+              {(file) => {
+                const active = createMemo(() => expanded().includes(file.filePath))
+                const [visible, setVisible] = createSignal(false)
+
+                createEffect(() => {
+                  if (!active()) {
+                    setVisible(false)
+                    return
+                  }
+
+                  requestAnimationFrame(() => {
+                    if (!active()) return
+                    setVisible(true)
+                  })
+                })
+
+                return (
+                  <Accordion.Item value={file.filePath} data-type={file.type}>
+                    <Accordion.Header>
+                      <Accordion.Trigger>
+                        <div data-slot="apply-patch-trigger-content">
+                          <span data-slot="apply-patch-file-path">{file.relativePath}</span>
+                          <div data-slot="apply-patch-trigger-actions">
+                            <Switch>
+                              <Match when={file.type === "delete"}>
+                                <span data-slot="apply-patch-file-action" data-type="delete">
+                                  {i18n.t("ui.patch.action.deleted")}
+                                </span>
+                              </Match>
+                              <Match when={file.type === "add"}>
+                                <span data-slot="apply-patch-file-action" data-type="add">
+                                  {i18n.t("ui.patch.action.created")}
+                                </span>
+                              </Match>
+                              <Match when={file.type === "move"}>
+                                <span data-slot="apply-patch-file-action" data-type="move">
+                                  {i18n.t("ui.patch.action.moved")}
+                                </span>
+                              </Match>
+                            </Switch>
+                            <Show when={file.type !== "delete"}>
+                              <DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
+                            </Show>
+                            <Show when={file.type === "delete"}>
+                              <span data-slot="apply-patch-deletion-count">-{file.deletions}</span>
+                            </Show>
+                            <span data-slot="apply-patch-file-chevron">
+                              <Icon name="chevron-down" size="small" />
+                            </span>
+                          </div>
+                        </div>
+                      </Accordion.Trigger>
+                    </Accordion.Header>
+                    <Accordion.Content>
+                      <Show when={visible()}>
+                        <div data-component="apply-patch-file-diff">
+                          <Dynamic
+                            component={diffComponent}
+                            before={{ name: file.filePath, contents: file.before }}
+                            after={{ name: file.movePath ?? file.filePath, contents: file.after }}
+                          />
+                        </div>
+                      </Show>
+                    </Accordion.Content>
+                  </Accordion.Item>
+                )
+              }}
             </For>
             </For>
-          </div>
+          </Accordion>
         </Show>
         </Show>
       </BasicTool>
       </BasicTool>
     )
     )

+ 30 - 9
packages/ui/src/components/session-turn.css

@@ -130,19 +130,13 @@
     gap: 12px;
     gap: 12px;
   }
   }
 
 
-  [data-component="session-turn-diff"] {
-    border: 1px solid var(--border-weaker-base);
-    border-radius: var(--radius-md);
-    overflow: clip;
-  }
-
-  [data-slot="session-turn-diff-header"] {
+  [data-slot="session-turn-diff-trigger"] {
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
     justify-content: space-between;
     justify-content: space-between;
     gap: 12px;
     gap: 12px;
-    padding: 6px 10px;
-    border-bottom: 1px solid var(--border-weaker-base);
+    width: 100%;
+    min-width: 0;
   }
   }
 
 
   [data-slot="session-turn-diff-path"] {
   [data-slot="session-turn-diff-path"] {
@@ -166,9 +160,36 @@
     font-weight: var(--font-weight-medium);
     font-weight: var(--font-weight-medium);
   }
   }
 
 
+  [data-slot="session-turn-diff-meta"] {
+    flex-shrink: 0;
+    display: inline-flex;
+    align-items: center;
+    gap: 10px;
+  }
+
+  [data-slot="session-turn-diff-chevron"] {
+    display: inline-flex;
+    color: var(--icon-weaker);
+    transform: rotate(-90deg);
+    transition: transform 0.15s ease;
+  }
+
+  [data-slot="accordion-item"][data-expanded] [data-slot="session-turn-diff-chevron"] {
+    transform: rotate(0deg);
+  }
+
   [data-slot="session-turn-diff-view"] {
   [data-slot="session-turn-diff-view"] {
     background-color: var(--surface-inset-base);
     background-color: var(--surface-inset-base);
     width: 100%;
     width: 100%;
     min-width: 0;
     min-width: 0;
+    max-height: 420px;
+    overflow-y: auto;
+    overflow-x: hidden;
+    scrollbar-width: none;
+    -ms-overflow-style: none;
+  }
+
+  [data-slot="session-turn-diff-view"]::-webkit-scrollbar {
+    display: none;
   }
   }
 }
 }

+ 85 - 26
packages/ui/src/components/session-turn.tsx

@@ -4,12 +4,14 @@ import { useDiffComponent } from "../context/diff"
 
 
 import { Binary } from "@opencode-ai/util/binary"
 import { Binary } from "@opencode-ai/util/binary"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createMemo, createSignal, For, ParentProps, Show } from "solid-js"
+import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import { Dynamic } from "solid-js/web"
 import { AssistantParts, Message } from "./message-part"
 import { AssistantParts, Message } from "./message-part"
 import { Card } from "./card"
 import { Card } from "./card"
+import { Accordion } from "./accordion"
 import { Collapsible } from "./collapsible"
 import { Collapsible } from "./collapsible"
 import { DiffChanges } from "./diff-changes"
 import { DiffChanges } from "./diff-changes"
+import { Icon } from "./icon"
 import { TextShimmer } from "./text-shimmer"
 import { TextShimmer } from "./text-shimmer"
 import { createAutoScroll } from "../hooks"
 import { createAutoScroll } from "../hooks"
 import { useI18n } from "../context/i18n"
 import { useI18n } from "../context/i18n"
@@ -175,6 +177,17 @@ export function SessionTurn(
   })
   })
   const edited = createMemo(() => diffs().length)
   const edited = createMemo(() => diffs().length)
   const [open, setOpen] = createSignal(false)
   const [open, setOpen] = createSignal(false)
+  const [expanded, setExpanded] = createSignal<string[]>([])
+
+  createEffect(
+    on(
+      open,
+      (value, prev) => {
+        if (!value && prev) setExpanded([])
+      },
+      { defer: true },
+    ),
+  )
 
 
   const assistantMessages = createMemo(
   const assistantMessages = createMemo(
     () => {
     () => {
@@ -280,7 +293,7 @@ export function SessionTurn(
                     />
                     />
                   </div>
                   </div>
                 </Show>
                 </Show>
-                <Show when={edited() > 0}>
+                <Show when={edited() > 0 && !working()}>
                   <div data-slot="session-turn-diffs">
                   <div data-slot="session-turn-diffs">
                     <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
                     <Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
                       <Collapsible.Trigger>
                       <Collapsible.Trigger>
@@ -302,30 +315,76 @@ export function SessionTurn(
                       <Collapsible.Content>
                       <Collapsible.Content>
                         <Show when={open()}>
                         <Show when={open()}>
                           <div data-component="session-turn-diffs-content">
                           <div data-component="session-turn-diffs-content">
-                            <For each={diffs()}>
-                              {(diff) => (
-                                <div data-component="session-turn-diff">
-                                  <div data-slot="session-turn-diff-header">
-                                    <span data-slot="session-turn-diff-path">
-                                      <Show when={diff.file.includes("/")}>
-                                        <span data-slot="session-turn-diff-directory">{getDirectory(diff.file)}</span>
-                                      </Show>
-                                      <span data-slot="session-turn-diff-filename">{getFilename(diff.file)}</span>
-                                    </span>
-                                    <span data-slot="session-turn-diff-changes">
-                                      <DiffChanges changes={diff} />
-                                    </span>
-                                  </div>
-                                  <div data-slot="session-turn-diff-view">
-                                    <Dynamic
-                                      component={diffComponent}
-                                      before={{ name: diff.file, contents: diff.before }}
-                                      after={{ name: diff.file, contents: diff.after }}
-                                    />
-                                  </div>
-                                </div>
-                              )}
-                            </For>
+                            <Accordion
+                              multiple
+                              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}>
+                                      <Accordion.Header>
+                                        <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">
+                                                  {getDirectory(diff.file)}
+                                                </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>
+                                          </div>
+                                        </Accordion.Trigger>
+                                      </Accordion.Header>
+                                      <Accordion.Content>
+                                        <Show when={visible()}>
+                                          <div data-slot="session-turn-diff-view" data-scrollable>
+                                            <Dynamic
+                                              component={diffComponent}
+                                              before={{ name: diff.file, contents: diff.before }}
+                                              after={{ name: diff.file, contents: diff.after }}
+                                            />
+                                          </div>
+                                        </Show>
+                                      </Accordion.Content>
+                                    </Accordion.Item>
+                                  )
+                                }}
+                              </For>
+                            </Accordion>
                           </div>
                           </div>
                         </Show>
                         </Show>
                       </Collapsible.Content>
                       </Collapsible.Content>