Animation Smorgasbord (#15637)

Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Kit Langton
2026-03-02 17:24:32 -05:00
committed by GitHub
parent 78069369e2
commit 9d7852b5c3
62 changed files with 5231 additions and 710 deletions

View File

@@ -5,6 +5,7 @@ import {
createSignal,
For,
Match,
onMount,
Show,
Switch,
onCleanup,
@@ -47,6 +48,42 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
import { animate } from "motion"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
let valueRef: HTMLSpanElement | undefined
onMount(() => {
if (!props.animate) return
requestAnimationFrame(() => {
if (widthRef) {
animate(widthRef, { width: "auto" }, { type: "spring", visualDuration: 0.25, bounce: 0 })
}
if (valueRef) {
animate(valueRef, { opacity: 1, filter: "blur(0px)" }, { duration: 0.32, ease: [0.16, 1, 0.3, 1] })
}
})
})
return (
<span data-component="shell-submessage">
<span ref={widthRef} data-slot="shell-submessage-width" style={{ width: props.animate ? "0px" : undefined }}>
<span data-slot="basic-tool-tool-subtitle">
<span
ref={valueRef}
data-slot="shell-submessage-value"
style={props.animate ? { opacity: 0, filter: "blur(2px)" } : undefined}
>
{props.text}
</span>
</span>
</span>
</span>
)
}
interface Diagnostic {
range: {
@@ -272,6 +309,102 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback
}
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type PartRef = {
messageID: string
partID: string
}
type PartGroup =
| {
key: string
type: "part"
ref: PartRef
}
| {
key: string
type: "context"
refs: PartRef[]
}
function sameRef(a: PartRef, b: PartRef) {
return a.messageID === b.messageID && a.partID === b.partID
}
function sameGroup(a: PartGroup, b: PartGroup) {
if (a === b) return true
if (a.key !== b.key) return false
if (a.type !== b.type) return false
if (a.type === "part") {
if (b.type !== "part") return false
return sameRef(a.ref, b.ref)
}
if (b.type !== "context") return false
if (a.refs.length !== b.refs.length) return false
return a.refs.every((ref, i) => sameRef(ref, b.refs[i]!))
}
function sameGroups(a: readonly PartGroup[] | undefined, b: readonly PartGroup[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((item, i) => sameGroup(item, b[i]!))
}
function groupParts(parts: { messageID: string; part: PartType }[]) {
const result: PartGroup[] = []
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
result.push({
key: `context:${first.part.id}`,
type: "context",
refs: parts.slice(start, end + 1).map((item) => ({
messageID: item.messageID,
partID: item.part.id,
})),
})
start = -1
}
parts.forEach((item, index) => {
if (isContextGroupTool(item.part)) {
if (start < 0) start = index
return
}
flush(index - 1)
result.push({
key: `part:${item.messageID}:${item.part.id}`,
type: "part",
ref: {
messageID: item.messageID,
partID: item.part.id,
},
})
})
flush(parts.length - 1)
return result
}
function partByID(parts: readonly PartType[], partID: string) {
return parts.find((part) => part.id === partID)
}
function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
@@ -304,98 +437,68 @@ export function AssistantParts(props: {
}) {
const data = useData()
const emptyParts: PartType[] = []
const emptyTools: ToolPart[] = []
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<
string,
{ type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] }
> = {}
const push = (
key: string,
item: { type: "part"; part: PartType; message: AssistantMessage } | { type: "context"; parts: ToolPart[] },
) => {
keys.push(key)
items[key] = item
}
const grouped = createMemo(
() =>
groupParts(
props.messages.flatMap((message) =>
list(data.store.part?.[message.id], emptyParts)
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({
messageID: message.id,
part,
})),
),
),
[] as PartGroup[],
{ equals: sameGroups },
)
const parts = props.messages.flatMap((message) =>
list(data.store.part?.[message.id], emptyParts)
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({ message, part })),
)
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.part.id}`, {
type: "context",
parts: parts
.slice(start, end + 1)
.map((x) => x.part)
.filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((item, index) => {
if (isContextGroupTool(item.part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${item.message.id}:${item.part.id}`, { type: "part", part: item.part, message: item.message })
})
flush(parts.length - 1)
return { keys, items }
})
const last = createMemo(() => grouped().keys.at(-1))
const last = createMemo(() => grouped().at(-1)?.key)
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
const ctx = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "context") return
return value
})
const part = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "part") return
return value
})
const tail = createMemo(() => last() === key)
<For each={grouped()}>
{(entry) => {
if (entry.type === "context") {
const parts = createMemo(
() =>
entry.refs
.map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part)),
emptyTools,
{ equals: same },
)
const busy = createMemo(() => props.working && last() === entry.key)
return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} busy={busy()} />
</Show>
)
}
const message = createMemo(() => props.messages.find((item) => item.id === entry.ref.messageID))
const part = createMemo(() =>
partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID),
)
return (
<>
<Show when={ctx()}>
{(entry) => <ContextToolGroup parts={entry().parts} busy={props.working && tail()} />}
</Show>
<Show when={part()}>
{(entry) => (
<Part
part={entry().part}
message={entry().message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(entry().part, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
)}
</Show>
</>
<Show when={message()}>
{(message) => (
<Show when={part()}>
{(part) => (
<Part
part={part()}
message={message()}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(part(), props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
)}
</Show>
)}
</Show>
)
}}
</For>
@@ -469,23 +572,11 @@ function contextToolTrigger(part: ToolPart, i18n: ReturnType<typeof useI18n>) {
}
}
function contextToolSummary(parts: ToolPart[], i18n: ReturnType<typeof useI18n>) {
function contextToolSummary(parts: ToolPart[]) {
const read = parts.filter((part) => part.tool === "read").length
const search = parts.filter((part) => part.tool === "glob" || part.tool === "grep").length
const list = parts.filter((part) => part.tool === "list").length
return [
read
? i18n.t(read === 1 ? "ui.messagePart.context.read.one" : "ui.messagePart.context.read.other", { count: read })
: undefined,
search
? i18n.t(search === 1 ? "ui.messagePart.context.search.one" : "ui.messagePart.context.search.other", {
count: search,
})
: undefined,
list
? i18n.t(list === 1 ? "ui.messagePart.context.list.one" : "ui.messagePart.context.list.other", { count: list })
: undefined,
].filter((value): value is string => !!value)
return { read, search, list }
}
export function registerPartComponent(type: string, component: PartComponent) {
@@ -525,78 +616,49 @@ export function AssistantMessageDisplay(props: {
showAssistantCopyPartID?: string | null
showReasoningSummaries?: boolean
}) {
const grouped = createMemo(() => {
const keys: string[] = []
const items: Record<string, { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }> = {}
const push = (key: string, item: { type: "part"; part: PartType } | { type: "context"; parts: ToolPart[] }) => {
keys.push(key)
items[key] = item
}
const parts = props.parts
let start = -1
const flush = (end: number) => {
if (start < 0) return
const first = parts[start]
const last = parts[end]
if (!first || !last) {
start = -1
return
}
push(`context:${first.id}`, {
type: "context",
parts: parts.slice(start, end + 1).filter((part): part is ToolPart => isContextGroupTool(part)),
})
start = -1
}
parts.forEach((part, index) => {
if (!renderable(part, props.showReasoningSummaries ?? true)) return
if (isContextGroupTool(part)) {
if (start < 0) start = index
return
}
flush(index - 1)
push(`part:${part.id}`, { type: "part", part })
})
flush(parts.length - 1)
return { keys, items }
})
const emptyTools: ToolPart[] = []
const grouped = createMemo(
() =>
groupParts(
props.parts
.filter((part) => renderable(part, props.showReasoningSummaries ?? true))
.map((part) => ({
messageID: props.message.id,
part,
})),
),
[] as PartGroup[],
{ equals: sameGroups },
)
return (
<For each={grouped().keys}>
{(key) => {
const item = createMemo(() => grouped().items[key])
const ctx = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "context") return
return value
})
const part = createMemo(() => {
const value = item()
if (!value) return
if (value.type !== "part") return
return value
})
return (
<>
<Show when={ctx()}>{(entry) => <ContextToolGroup parts={entry().parts} />}</Show>
<Show when={part()}>
{(entry) => (
<Part
part={entry().part}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
)}
<For each={grouped()}>
{(entry) => {
if (entry.type === "context") {
const parts = createMemo(
() =>
entry.refs
.map((ref) => partByID(props.parts, ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part)),
emptyTools,
{ equals: same },
)
return (
<Show when={parts().length > 0}>
<ContextToolGroup parts={parts()} />
</Show>
</>
)
}
const part = createMemo(() => partByID(props.parts, entry.ref.partID))
return (
<Show when={part()}>
{(part) => (
<Part part={part()} message={props.message} showAssistantCopyPartID={props.showAssistantCopyPartID} />
)}
</Show>
)
}}
</For>
@@ -610,33 +672,53 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
() =>
!!props.busy || props.parts.some((part) => part.state.status === "pending" || part.state.status === "running"),
)
const summary = createMemo(() => contextToolSummary(props.parts, i18n))
const details = createMemo(() => summary().join(", "))
const summary = createMemo(() => contextToolSummary(props.parts))
return (
<Collapsible open={open()} onOpenChange={setOpen} variant="ghost">
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<Show
when={pending()}
fallback={
<span data-slot="context-tool-group-title">
<span data-slot="context-tool-group-label">{i18n.t("ui.sessionTurn.status.gatheredContext")}</span>
<Show when={details().length}>
<span data-slot="context-tool-group-summary">{details()}</span>
</Show>
</span>
}
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-title">
<span data-slot="context-tool-group-label">
<TextShimmer text={i18n.t("ui.sessionTurn.status.gatheringContext")} />
</span>
<Show when={details().length}>
<span data-slot="context-tool-group-summary">{details()}</span>
</Show>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={pending()}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
</Show>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{
key: "read",
count: summary().read,
one: i18n.t("ui.messagePart.context.read.one"),
other: i18n.t("ui.messagePart.context.read.other"),
},
{
key: "search",
count: summary().search,
one: i18n.t("ui.messagePart.context.search.one"),
other: i18n.t("ui.messagePart.context.search.other"),
},
{
key: "list",
count: summary().list,
one: i18n.t("ui.messagePart.context.list.one"),
other: i18n.t("ui.messagePart.context.list.other"),
},
]}
fallback=""
/>
</span>
</span>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
@@ -654,9 +736,7 @@ function ContextToolGroup(props: { parts: ToolPart[]; busy?: boolean }) {
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<Show when={running} fallback={trigger.title}>
<TextShimmer text={trigger.title} />
</Show>
<TextShimmer text={trigger.title} active={running} />
</span>
<Show when={!running && trigger.subtitle}>
<span data-slot="basic-tool-tool-subtitle">{trigger.subtitle}</span>
@@ -1319,9 +1399,7 @@ ToolRegistry.register({
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<Show when={pending()} fallback={i18n.t("ui.tool.webfetch")}>
<TextShimmer text={i18n.t("ui.tool.webfetch")} />
</Show>
<TextShimmer text={i18n.t("ui.tool.webfetch")} active={pending()} />
</span>
<Show when={!pending() && url()}>
<a
@@ -1436,6 +1514,8 @@ ToolRegistry.register({
name: "bash",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
const sawPending = pending()
const text = createMemo(() => {
const cmd = props.input.command ?? props.metadata.command ?? ""
const out = stripAnsi(props.output || props.metadata.output || "")
@@ -1455,10 +1535,18 @@ ToolRegistry.register({
<BasicTool
{...props}
icon="console"
trigger={{
title: i18n.t("ui.tool.shell"),
subtitle: props.input.description,
}}
trigger={
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.tool.shell")} active={pending()} />
</span>
<Show when={!pending() && props.input.description}>
<ShellSubmessage text={props.input.description} animate={sawPending} />
</Show>
</div>
</div>
}
>
<div data-component="bash-output">
<div data-slot="bash-copy">
@@ -1508,9 +1596,7 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.edit")}>
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} />
</Show>
<TextShimmer text={i18n.t("ui.messagePart.title.edit")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
@@ -1580,9 +1666,7 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.messagePart.title.write")}>
<TextShimmer text={i18n.t("ui.messagePart.title.write")} />
</Show>
<TextShimmer text={i18n.t("ui.messagePart.title.write")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{filename()}</span>
@@ -1774,9 +1858,7 @@ ToolRegistry.register({
<div data-slot="message-part-title-area">
<div data-slot="message-part-title">
<span data-slot="message-part-title-text">
<Show when={pending()} fallback={i18n.t("ui.tool.patch")}>
<TextShimmer text={i18n.t("ui.tool.patch")} />
</Show>
<TextShimmer text={i18n.t("ui.tool.patch")} active={pending()} />
</span>
<Show when={!pending()}>
<span data-slot="message-part-title-filename">{getFilename(file().relativePath)}</span>