summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/ui/src/components/basic-tool.tsx40
-rw-r--r--packages/ui/src/components/message-part.css60
-rw-r--r--packages/ui/src/components/message-part.tsx134
-rw-r--r--packages/ui/src/components/session-turn.css39
-rw-r--r--packages/ui/src/components/session-turn.tsx111
5 files changed, 274 insertions, 110 deletions
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 5cc4367a6..53bdc9ce1 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/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 type { IconProps } from "./icon"
import { TextShimmer } from "./text-shimmer"
@@ -27,18 +27,52 @@ export interface BasicToolProps {
hideDetails?: boolean
defaultOpen?: boolean
forceOpen?: boolean
+ defer?: boolean
locked?: boolean
onSubtitleClick?: () => void
}
export function BasicTool(props: BasicToolProps) {
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
+ const [ready, setReady] = createSignal(open())
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(() => {
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) => {
if (pending()) return
if (props.locked && !value) return
@@ -114,7 +148,9 @@ export function BasicTool(props: BasicToolProps) {
</div>
</Collapsible.Trigger>
<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>
</Collapsible>
)
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index bfcedde83..60c411bfd 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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="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-radius: 6px;
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;
- gap: 8px;
- padding: 8px 12px;
- background-color: transparent;
+ gap: 10px;
}
[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"] {
font-family: var(--font-family-mono);
font-size: var(--font-size-small);
color: var(--text-critical-base);
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"] {
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 24ae16a31..875f88611 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -35,6 +35,7 @@ import { useDialog } from "../context/dialog"
import { useI18n } from "../context/i18n"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
+import { Accordion } from "./accordion"
import { Button } from "./button"
import { Card } from "./card"
import { Collapsible } from "./collapsible"
@@ -1482,6 +1483,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
+ defer
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
@@ -1542,6 +1544,7 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
+ defer
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
@@ -1602,6 +1605,16 @@ ToolRegistry.register({
const i18n = useI18n()
const diffComponent = useDiffComponent()
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 count = files().length
@@ -1613,60 +1626,89 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="code-lines"
+ defer
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
}}
>
<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()}>
- {(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>
- </div>
+ </Accordion>
</Show>
</BasicTool>
)
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 5d58f0f71..e7da2b6f0 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -130,19 +130,13 @@
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;
align-items: center;
justify-content: space-between;
gap: 12px;
- padding: 6px 10px;
- border-bottom: 1px solid var(--border-weaker-base);
+ width: 100%;
+ min-width: 0;
}
[data-slot="session-turn-diff-path"] {
@@ -166,9 +160,36 @@
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"] {
background-color: var(--surface-inset-base);
width: 100%;
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;
}
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index e4c0a2273..a418fddd9 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -4,12 +4,14 @@ import { useDiffComponent } from "../context/diff"
import { Binary } from "@opencode-ai/util/binary"
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 { AssistantParts, Message } from "./message-part"
import { Card } from "./card"
+import { Accordion } from "./accordion"
import { Collapsible } from "./collapsible"
import { DiffChanges } from "./diff-changes"
+import { Icon } from "./icon"
import { TextShimmer } from "./text-shimmer"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
@@ -175,6 +177,17 @@ export function SessionTurn(
})
const edited = createMemo(() => diffs().length)
const [open, setOpen] = createSignal(false)
+ const [expanded, setExpanded] = createSignal<string[]>([])
+
+ createEffect(
+ on(
+ open,
+ (value, prev) => {
+ if (!value && prev) setExpanded([])
+ },
+ { defer: true },
+ ),
+ )
const assistantMessages = createMemo(
() => {
@@ -280,7 +293,7 @@ export function SessionTurn(
/>
</div>
</Show>
- <Show when={edited() > 0}>
+ <Show when={edited() > 0 && !working()}>
<div data-slot="session-turn-diffs">
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
@@ -302,30 +315,76 @@ export function SessionTurn(
<Collapsible.Content>
<Show when={open()}>
<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>
</Show>
</Collapsible.Content>