diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index db0df6fe3a..cec0943542 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -550,8 +550,15 @@ export default function FileTree(props: { - - + + diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index dcd5bd2f10..1ca085a428 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1375,8 +1375,7 @@ export const PromptInput: Component = (props) => { name={mode === "shell" ? "console" : "prompt"} class="size-[18px]" classList={{ - "text-icon-strong-base": mode === "shell" && store.mode === "shell", - "text-icon-interactive-base": mode === "normal" && store.mode === "normal", + "text-icon-strong-base": store.mode === mode, "text-icon-weak": store.mode !== mode, }} /> diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 5cc4367a68..53bdc9ce1e 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) { - {props.children} + + {props.children} + ) diff --git a/packages/ui/src/components/file-icon.css b/packages/ui/src/components/file-icon.css index 078352096c..a49674a90d 100644 --- a/packages/ui/src/components/file-icon.css +++ b/packages/ui/src/components/file-icon.css @@ -23,15 +23,3 @@ position: absolute; inset: 0; } - -[data-component="filetree"] .filetree-iconpair .filetree-icon--color { - opacity: 0; -} - -[data-component="filetree"]:hover .filetree-iconpair .filetree-icon--color { - opacity: 1; -} - -[data-component="filetree"]:hover .filetree-iconpair .filetree-icon--mono { - opacity: 0; -} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index bfcedde832..9e571c9f16 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,64 +1218,72 @@ } } -[data-component="apply-patch-files"] { - display: flex; - flex-direction: column; -} - -[data-component="apply-patch-file"] { - display: flex; - flex-direction: column; - - [data-slot="apply-patch-file-header"] { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - background-color: transparent; +[data-component="accordion"][data-scope="apply-patch"] { + [data-slot="accordion-trigger"] { + background-color: var(--background-stronger) !important; } - [data-slot="apply-patch-file-action"] { + [data-slot="apply-patch-trigger-content"] { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 20px; + } + + [data-slot="apply-patch-file-info"] { + flex-grow: 1; + display: flex; + align-items: center; + gap: 20px; + min-width: 0; + } + + [data-slot="apply-patch-file-name-container"] { + display: flex; + flex-grow: 1; + min-width: 0; + } + + [data-slot="apply-patch-directory"] { + color: var(--text-base); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + direction: rtl; + text-align: left; + } + + [data-slot="apply-patch-filename"] { + color: var(--text-strong); + flex-shrink: 0; + } + + [data-slot="apply-patch-trigger-actions"] { + flex-shrink: 0; + display: flex; + gap: 16px; + align-items: center; + justify-content: flex-end; + } + + [data-slot="apply-patch-change"] { font-family: var(--font-family-sans); font-size: var(--font-size-small); font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); - color: var(--text-base); - flex-shrink: 0; - - &[data-type="delete"] { - color: var(--text-critical-base); - } - - &[data-type="add"] { - color: var(--text-success-base); - } - - &[data-type="move"] { - color: var(--text-warning-base); - } } - [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-change"][data-type="added"] { + color: var(--icon-diff-add-base); } - [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-slot="apply-patch-change"][data-type="removed"] { + color: var(--icon-diff-delete-base); } -} -[data-component="apply-patch-file"] + [data-component="apply-patch-file"] { - border-top: 1px solid var(--border-weaker-base); + [data-slot="apply-patch-change"][data-type="modified"] { + color: var(--icon-diff-modified-base); + } } [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 24ae16a319..3a19bf7d2b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -35,9 +35,11 @@ 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" +import { FileIcon } from "./file-icon" import { Icon } from "./icon" import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" @@ -670,13 +672,6 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? []) - const provider = createMemo(() => { - const id = props.message.model?.providerID - if (!id) return "" - const match = data.store.provider?.all?.find((p) => p.id === id) - return match?.name ?? id - }) - const model = createMemo(() => { const providerID = props.message.model?.providerID const modelID = props.message.model?.modelID @@ -697,7 +692,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp const metaHead = createMemo(() => { const agent = props.message.agent - const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", provider(), model()] + const items = [agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", model()] return items.filter((x) => !!x).join("\u00A0\u00B7\u00A0") }) @@ -1053,13 +1048,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { props.message.role === "assistant" && (props.message as AssistantMessage).error?.name === "MessageAbortedError", ) - const provider = createMemo(() => { - if (props.message.role !== "assistant") return "" - const id = (props.message as AssistantMessage).providerID - const match = data.store.provider?.all?.find((p) => p.id === id) - return match?.name ?? id - }) - const model = createMemo(() => { if (props.message.role !== "assistant") return "" const message = props.message as AssistantMessage @@ -1074,9 +1062,10 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { if (typeof completed !== "number") return "" const ms = completed - message.time.created if (!(ms >= 0)) return "" - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` - const minutes = Math.floor(ms / 60_000) - const seconds = Math.round((ms - minutes * 60_000) / 1000) + const total = Math.round(ms / 1000) + if (total < 60) return `${total}s` + const minutes = Math.floor(total / 60) + const seconds = total % 60 return `${minutes}m ${seconds}s` }) @@ -1085,7 +1074,6 @@ PART_MAPPING["text"] = function TextPartDisplay(props) { const agent = (props.message as AssistantMessage).agent const items = [ agent ? agent[0]?.toUpperCase() + agent.slice(1) : "", - provider(), model(), duration(), interrupted() ? i18n.t("ui.message.interrupted") : "", @@ -1482,6 +1470,7 @@ ToolRegistry.register({
@@ -1542,6 +1531,7 @@ ToolRegistry.register({
@@ -1602,6 +1592,16 @@ ToolRegistry.register({ const i18n = useI18n() const diffComponent = useDiffComponent() const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + const [expanded, setExpanded] = createSignal([]) + 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 +1613,92 @@ ToolRegistry.register({ 0}> -
+ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > - {(file) => ( -
-
- - - - {i18n.t("ui.patch.action.deleted")} - - - - - {i18n.t("ui.patch.action.created")} - - - - - {i18n.t("ui.patch.action.moved")} - - - - - {i18n.t("ui.patch.action.patched")} - - - - {file.relativePath} - - - - - -{file.deletions} - -
- -
- -
-
-
- )} + {(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 ( + + + +
+
+ +
+ + {`\u202A${getDirectory(file.relativePath)}\u202C`} + + {getFilename(file.relativePath)} +
+
+
+ + + + {i18n.t("ui.patch.action.created")} + + + + + {i18n.t("ui.patch.action.deleted")} + + + + + {i18n.t("ui.patch.action.moved")} + + + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }}
-
+
) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 5d58f0f711..e7da2b6f05 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 e4c0a22734..a418fddd90 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([]) + + createEffect( + on( + open, + (value, prev) => { + if (!value && prev) setExpanded([]) + }, + { defer: true }, + ), + ) const assistantMessages = createMemo( () => { @@ -280,7 +293,7 @@ export function SessionTurn( />
- 0}> + 0 && !working()}>
@@ -302,30 +315,76 @@ export function SessionTurn(
- - {(diff) => ( -
-
- - - {getDirectory(diff.file)} - - {getFilename(diff.file)} - - - - -
-
- -
-
- )} -
+ setExpanded(Array.isArray(value) ? value : value ? [value] : [])} + > + + {(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 ( + + + +
+ + + + {getDirectory(diff.file)} + + + + {getFilename(diff.file)} + + +
+ + + + + + +
+
+
+
+ + +
+ +
+
+
+
+ ) + }} +
+