summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-12 22:50:50 +0000
committerDavid Hill <[email protected]>2025-12-12 22:50:50 +0000
commitdbc84ff4c347aa446e18b80ef1811c5fd9c886f9 (patch)
treed4b4640a6f90ad62b57d6942a2fe3fef232f86bf /packages/ui/src
parentc11ea3fd923957d8f6c94878e69babdbad194e31 (diff)
parent3c3a0f8afbc1325ab53985995826f5ccf6c80737 (diff)
downloadopencode-dbc84ff4c347aa446e18b80ef1811c5fd9c886f9.tar.gz
opencode-dbc84ff4c347aa446e18b80ef1811c5fd9c886f9.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/basic-tool.tsx3
-rw-r--r--packages/ui/src/components/button.css20
-rw-r--r--packages/ui/src/components/button.tsx2
-rw-r--r--packages/ui/src/components/message-part.css26
-rw-r--r--packages/ui/src/components/message-part.tsx47
-rw-r--r--packages/ui/src/components/message-progress.css50
-rw-r--r--packages/ui/src/components/message-progress.tsx179
-rw-r--r--packages/ui/src/components/session-turn.css66
-rw-r--r--packages/ui/src/components/session-turn.tsx571
-rw-r--r--packages/ui/src/components/spinner.tsx20
-rw-r--r--packages/ui/src/components/typewriter.tsx13
-rw-r--r--packages/ui/src/styles/animations.css10
-rw-r--r--packages/ui/src/styles/index.css1
-rw-r--r--packages/ui/src/styles/theme.css12
14 files changed, 496 insertions, 524 deletions
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 596eef00b..4fab331a5 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/packages/ui/src/components/basic-tool.tsx
@@ -21,12 +21,13 @@ export interface BasicToolProps {
trigger: TriggerTitle | JSX.Element
children?: JSX.Element
hideDetails?: boolean
+ defaultOpen?: boolean
}
export function BasicTool(props: BasicToolProps) {
const resolved = children(() => props.children)
return (
- <Collapsible>
+ <Collapsible defaultOpen={props.defaultOpen}>
<Collapsible.Trigger>
<div data-component="tool-trigger">
<div data-slot="basic-tool-tool-trigger-content">
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index 3a32672fe..c5bd2c696 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -100,6 +100,26 @@
}
}
+ &[data-size="small"] {
+ height: 22px;
+ padding: 0 8px;
+ &[data-icon] {
+ padding: 0 12px 0 4px;
+ }
+
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-large);
+ gap: 4px;
+
+ /* text-12-medium */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+
&[data-size="normal"] {
height: 24px;
padding: 0 6px;
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 0802c3629..7f974b2f7 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -5,7 +5,7 @@ import { Icon, IconProps } from "./icon"
export interface ButtonProps
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
- size?: "normal" | "large"
+ size?: "small" | "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
icon?: IconProps["name"]
}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 1ccee7320..9d4214bae 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -29,6 +29,16 @@
}
}
+[data-component="reasoning-part"] {
+ width: 100%;
+ opacity: 0.5;
+
+ [data-component="markdown"] {
+ margin-top: 24px;
+ font-style: italic !important;
+ }
+}
+
[data-component="tool-error"] {
display: flex;
align-items: start;
@@ -74,6 +84,22 @@
margin: 0;
padding: 0;
}
+
+ &[data-scrollable] {
+ height: auto;
+ max-height: 240px;
+ overflow-y: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ [data-component="markdown"] {
+ overflow: visible;
+ }
+ }
}
[data-component="edit-trigger"],
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index a28e36aa8..a596b811e 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -8,6 +8,7 @@ import {
ToolPart,
UserMessage,
} from "@opencode-ai/sdk/v2"
+import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { BasicTool } from "./basic-tool"
import { GenericTool } from "./basic-tool"
@@ -16,27 +17,34 @@ import { Icon } from "./icon"
import { Checkbox } from "./checkbox"
import { DiffChanges } from "./diff-changes"
import { Markdown } from "./markdown"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { sanitizePart } from "@opencode-ai/util/sanitize"
-import { unwrap } from "solid-js/store"
+import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
export interface MessageProps {
message: MessageType
parts: PartType[]
- sanitize?: RegExp
}
export interface MessagePartProps {
part: PartType
message: MessageType
hideDetails?: boolean
- sanitize?: RegExp
}
export type PartComponent = Component<MessagePartProps>
export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
+function relativizeProjectPaths(text: string, directory?: string) {
+ if (!text) return ""
+ if (!directory) return text
+ return text.split(directory).join("")
+}
+
+function getDirectory(path: string | undefined) {
+ const data = useData()
+ return relativizeProjectPaths(_getDirectory(path), data.directory)
+}
+
export function registerPartComponent(type: string, component: PartComponent) {
PART_MAPPING[type] = component
}
@@ -49,27 +57,20 @@ export function Message(props: MessageProps) {
</Match>
<Match when={props.message.role === "assistant" && props.message}>
{(assistantMessage) => (
- <AssistantMessageDisplay
- message={assistantMessage() as AssistantMessage}
- parts={props.parts}
- sanitize={props.sanitize}
- />
+ <AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
)}
</Match>
</Switch>
)
}
-export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
+export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
const filteredParts = createMemo(() => {
return props.parts?.filter((x) => {
- if (x.type === "reasoning") return false
return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
})
})
- return (
- <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
- )
+ return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
}
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
@@ -84,10 +85,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
export function Part(props: MessagePartProps) {
const component = createMemo(() => PART_MAPPING[props.part.type])
- const part = createMemo(() => sanitizePart(unwrap(props.part), props.sanitize))
return (
<Show when={component()}>
- <Dynamic component={component()} part={part()} message={props.message} hideDetails={props.hideDetails} />
+ <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
</Show>
)
}
@@ -175,12 +175,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
}
PART_MAPPING["text"] = function TextPartDisplay(props) {
+ const data = useData()
const part = props.part as TextPart
- const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(unwrap(part), props.sanitize) as TextPart) : part))
+ const content = createMemo(() => (part.text ?? "").trim())
+ const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory))
+
return (
- <Show when={part.text.trim()}>
+ <Show when={displayText()}>
<div data-component="text-part">
- <Markdown text={sanitized().text.trim()} />
+ <Markdown text={displayText()} />
</div>
</Show>
)
@@ -318,13 +321,14 @@ ToolRegistry.register({
render(props) {
return (
<BasicTool
+ defaultOpen
icon="console"
trigger={{
title: "Shell",
subtitle: props.input.description,
}}
>
- <div data-component="tool-output">
+ <div data-component="tool-output" data-scrollable>
<Markdown
text={`\`\`\`command\n$ ${props.input.command}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
/>
@@ -340,6 +344,7 @@ ToolRegistry.register({
const diffComponent = useDiffComponent()
return (
<BasicTool
+ defaultOpen
icon="code-lines"
trigger={
<div data-component="edit-trigger">
diff --git a/packages/ui/src/components/message-progress.css b/packages/ui/src/components/message-progress.css
deleted file mode 100644
index 0b84e0393..000000000
--- a/packages/ui/src/components/message-progress.css
+++ /dev/null
@@ -1,50 +0,0 @@
-[data-component="message-progress"] {
- display: flex;
- flex-direction: column;
- gap: 12px;
-}
-
-[data-component="message-progress"] [data-slot="message-progress-status"] {
- display: flex;
- align-items: center;
- column-gap: 20px;
- padding-left: 12px;
- border: 1px solid transparent;
- color: var(--text-base);
-}
-
-[data-component="message-progress"] [data-slot="message-progress-status-text"] {
- font-size: 12px;
- font-weight: 500;
- line-height: 1.5;
-}
-
-[data-component="message-progress"] [data-slot="message-progress-list-container"] {
- height: 120px;
- overflow: hidden;
- pointer-events: none;
- padding-bottom: 4px;
-
- mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%);
- -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%);
-}
-
-[data-component="message-progress"] [data-slot="message-progress-list"] {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- align-self: stretch;
- gap: 8px;
- padding-top: 32px;
- padding-bottom: 32px;
-
- transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
-}
-
-[data-component="message-progress"] [data-slot="message-progress-item"] {
- height: 32px;
- display: flex;
- align-items: center;
- width: 100%;
-}
diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx
deleted file mode 100644
index ef3548ab3..000000000
--- a/packages/ui/src/components/message-progress.tsx
+++ /dev/null
@@ -1,179 +0,0 @@
-import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
-import { Part } from "./message-part"
-import { Spinner } from "./spinner"
-import { useData } from "../context/data"
-import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk/v2"
-
-export interface MessageProgressProps {
- assistantMessages: () => AssistantMessageType[]
- done?: boolean
-}
-
-export function MessageProgress(props: MessageProgressProps) {
- const data = useData()
- const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
- const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.store.part[m.id]))
- const done = createMemo(() => props.done ?? false)
- const currentTask = createMemo(
- () =>
- parts().findLast(
- (p) =>
- p &&
- p.type === "tool" &&
- p.tool === "task" &&
- p.state &&
- "metadata" in p.state &&
- p.state.metadata &&
- p.state.metadata.sessionId &&
- p.state.status === "running",
- ) as ToolPart,
- )
- const resolvedParts = createMemo(() => {
- let resolved = parts()
- const task = currentTask()
- if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
- const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
- (m) => m.role === "assistant",
- )
- resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? parts()
- }
- return resolved
- })
-
- const eligibleItems = createMemo(() => {
- return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
- })
- const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
- <div data-slot="message-progress-item" />,
- <div data-slot="message-progress-item" />,
- <div data-slot="message-progress-item" />,
- ...eligibleItems(),
- ...(done()
- ? [
- <div data-slot="message-progress-item" />,
- <div data-slot="message-progress-item" />,
- <div data-slot="message-progress-item" />,
- ]
- : []),
- ])
-
- const delay = createMemo(() => (done() ? 220 : 400))
- const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
-
- createEffect(() => {
- const total = finishedItems().length
- if (total > visibleCount()) {
- const timer = setTimeout(() => {
- setVisibleCount((prev) => prev + 1)
- }, delay())
- onCleanup(() => clearTimeout(timer))
- } else if (total < visibleCount()) {
- setVisibleCount(total)
- }
- })
-
- const translateY = createMemo(() => {
- const total = visibleCount()
- if (total < 2) return "0px"
- return `-${(total - 2) * 40 - 8}px`
- })
-
- const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
- const rawStatus = createMemo(() => {
- const last = lastPart()
- if (!last) return undefined
-
- if (last.type === "tool") {
- switch (last.tool) {
- case "task":
- return "Delegating work..."
- case "todowrite":
- case "todoread":
- return "Planning next steps..."
- case "read":
- return "Gathering context..."
- case "list":
- case "grep":
- case "glob":
- return "Searching the codebase..."
- case "webfetch":
- return "Searching the web..."
- case "edit":
- case "write":
- return "Making edits..."
- case "bash":
- return "Running commands..."
- default:
- break
- }
- } else if (last.type === "reasoning") {
- return "Thinking..."
- } else if (last.type === "text") {
- return "Gathering thoughts..."
- }
- return undefined
- })
-
- const [status, setStatus] = createSignal(rawStatus())
- let lastStatusChange = Date.now()
- let statusTimeout: number | undefined
-
- createEffect(() => {
- const newStatus = rawStatus()
- if (newStatus === status() || !newStatus) return
-
- const timeSinceLastChange = Date.now() - lastStatusChange
-
- if (timeSinceLastChange >= 1500) {
- setStatus(newStatus)
- lastStatusChange = Date.now()
- if (statusTimeout) {
- clearTimeout(statusTimeout)
- statusTimeout = undefined
- }
- } else {
- if (statusTimeout) clearTimeout(statusTimeout)
- statusTimeout = setTimeout(() => {
- setStatus(rawStatus())
- lastStatusChange = Date.now()
- statusTimeout = undefined
- }, 1000 - timeSinceLastChange) as unknown as number
- }
- })
-
- return (
- <div data-component="message-progress">
- <div data-slot="message-progress-status">
- <Spinner /> <span data-slot="message-progress-status-text">{status() ?? "Considering next steps..."}</span>
- </div>
- <Show when={eligibleItems().length > 0}>
- <div data-slot="message-progress-list-container">
- <div data-slot="message-progress-list" style={{ transform: `translateY(${translateY()})` }}>
- <For each={finishedItems()}>
- {(part) => (
- <Switch>
- <Match when={part && typeof part === "object" && "type" in part && part}>
- {(p) => {
- const part = p() as ToolPart
- const message = createMemo(() =>
- data.store.message[part.sessionID].find((m) => m.id === part.messageID),
- )
- return (
- <div data-slot="message-progress-item">
- <Part message={message()!} part={part} sanitize={sanitizer()} />
- </div>
- )
- }}
- </Match>
- <Match when={true}>
- <div data-slot="message-progress-item">{part as JSXElement}</div>
- </Match>
- </Switch>
- )}
- </For>
- </div>
- </div>
- </Show>
- </div>
- )
-}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index d2a3d618a..c4dd2b839 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -29,20 +29,33 @@
gap: 32px;
}
+ [data-slot="session-turn-sticky-header"] {
+ width: 100%;
+ position: sticky;
+ top: 0;
+ background-color: var(--background-stronger);
+ z-index: 20;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-bottom: 8px;
+ }
+
[data-slot="session-turn-message-header"] {
display: flex;
align-items: center;
gap: 8px;
align-self: stretch;
- position: sticky;
- top: 0;
- background-color: var(--background-stronger);
- z-index: 20;
height: 32px;
}
- [data-slot="session-turn-message-content"] {
- margin-top: -24px;
+ /* [data-slot="session-turn-message-content"] { */
+ /* } */
+
+ [data-slot="session-turn-response-trigger"] {
+ width: calc(100% + 9px);
+ margin-left: -9px;
+ padding-left: 9px;
}
[data-slot="session-turn-message-title"] {
@@ -202,10 +215,10 @@
}
[data-component="sticky-accordion-header"] {
- top: 40px;
+ top: var(--sticky-header-height, 40px);
&[data-expanded]::before {
- top: -40px;
+ top: calc(-1 * var(--sticky-header-height, 40px));
}
}
@@ -270,26 +283,35 @@
}
[data-slot="session-turn-response-section"] {
- width: 100%;
+ width: calc(100% + 9px);
min-width: 0;
+ margin-left: -9px;
+ padding-left: 9px;
+ }
+
+ [data-slot="session-turn-collapsible"] {
+ gap: 32px;
+ overflow: visible;
}
[data-slot="session-turn-collapsible-trigger-content"] {
- color: var(--text-weak);
- cursor: pointer;
- background: none;
- border: none;
- padding: 0;
+ width: fit-content;
display: flex;
align-items: center;
+ gap: 4px;
+ color: var(--text-weak);
+ margin-left: -9px;
- &:hover {
- color: var(--text-strong);
+ [data-component="spinner"] {
+ width: 12px;
+ height: 12px;
+ margin-right: 4px;
+ }
+
+ [data-component="icon"] {
+ width: 14px;
+ height: 14px;
}
- display: flex;
- align-items: center;
- gap: 4px;
- align-self: stretch;
}
[data-slot="session-turn-details-text"] {
@@ -308,5 +330,9 @@
flex-direction: column;
align-self: stretch;
gap: 12px;
+
+ > :first-child > [data-component="markdown"]:first-child {
+ margin-top: 0;
+ }
}
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f97a3224c..708ac5b83 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -1,9 +1,21 @@
-import { AssistantMessage } from "@opencode-ai/sdk/v2"
+import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client"
import { useData } from "../context"
import { useDiffComponent } from "../context/diff"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
-import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
+import {
+ createEffect,
+ createMemo,
+ createSignal,
+ For,
+ Match,
+ onCleanup,
+ onMount,
+ ParentProps,
+ Show,
+ Switch,
+} from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
import { Typewriter } from "./typewriter"
import { Message } from "./message-part"
@@ -13,16 +25,11 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
import { Card } from "./card"
-import { MessageProgress } from "./message-progress"
-import { Collapsible } from "./collapsible"
import { Dynamic } from "solid-js/web"
-
-// Track animation state per message ID - persists across re-renders
-// "empty" = first saw with no value (should animate when value arrives)
-// "animating" = currently animating (keep returning true)
-// "done" = already animated or first saw with value (never animate)
-const titleAnimationState = new Map<string, "empty" | "animating" | "done">()
-const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
+import { Button } from "./button"
+import { Spinner } from "./spinner"
+import { createStore } from "solid-js/store"
+import { DateTime, DurationUnit, Interval } from "luxon"
export function SessionTurn(
props: ParentProps<{
@@ -37,18 +44,13 @@ export function SessionTurn(
) {
const data = useData()
const diffComponent = useDiffComponent()
- const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
- const lastUserMessage = createMemo(() => {
- return userMessages()?.at(-1)
- })
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
-
const status = createMemo(
() =>
data.store.session_status[props.sessionID] ?? {
@@ -57,241 +59,346 @@ export function SessionTurn(
)
const working = createMemo(() => status()?.type !== "idle")
- return (
- <div data-component="session-turn" class={props.classes?.root}>
- <div data-slot="session-turn-content" class={props.classes?.content}>
- <Show when={message()}>
- {(msg) => {
- const [detailsExpanded, setDetailsExpanded] = createSignal(false)
+ let scrollRef: HTMLDivElement | undefined
+ const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
+ const [stickyHeaderRef, setStickyHeaderRef] = createSignal<HTMLDivElement>()
+ const [userScrolled, setUserScrolled] = createSignal(false)
+ const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0)
- // Animation logic: only animate if we witness the value transition from empty to non-empty
- // Track in module-level Maps keyed by message ID so it persists across re-renders
+ function handleScroll() {
+ if (!scrollRef) return
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef
+ const atBottom = scrollHeight - scrollTop - clientHeight < 50
+ if (!atBottom && working()) {
+ setUserScrolled(true)
+ }
+ }
- // Initialize animation state for current message (reactive - runs when msg().id changes)
- createEffect(() => {
- const id = msg().id
- if (!titleAnimationState.has(id)) {
- titleAnimationState.set(id, msg().summary?.title ? "done" : "empty")
- }
- if (!summaryAnimationState.has(id)) {
- const assistantMsgs = messages()?.filter(
- (m) => m.role === "assistant" && m.parentID == id,
+ function handleInteraction() {
+ if (working()) {
+ setUserScrolled(true)
+ }
+ }
+
+ createEffect(() => {
+ if (!working()) {
+ setUserScrolled(false)
+ }
+ })
+
+ createResizeObserver(contentRef, () => {
+ if (!scrollRef || userScrolled() || !working()) return
+ scrollRef.scrollTop = scrollRef.scrollHeight
+ })
+
+ createResizeObserver(stickyHeaderRef, ({ height }) => {
+ setStickyHeaderHeight(height + 8)
+ })
+
+ return (
+ <div data-component="session-turn" class={props.classes?.root}>
+ <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
+ <div ref={setContentRef} onClick={handleInteraction}>
+ <Show when={message()}>
+ {(message) => {
+ const assistantMessages = createMemo(() => {
+ return messages()?.filter(
+ (m) => m.role === "assistant" && m.parentID == message().id,
) as AssistantMessage[]
- const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id])
- const lastText = parts?.filter((p) => p?.type === "text")?.at(-1)
- const summaryValue = msg().summary?.body ?? lastText?.text
- summaryAnimationState.set(id, summaryValue ? "done" : "empty")
- }
+ })
+ const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
+ const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+ const parts = createMemo(() => data.store.part[message().id])
+ const lastTextPart = createMemo(() =>
+ assistantMessageParts()
+ .filter((p) => p?.type === "text")
+ ?.at(-1),
+ )
+ const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
+ const lastTextPartShown = createMemo(
+ () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
+ )
- // When message changes or component unmounts, mark any "animating" states as "done"
- onCleanup(() => {
- if (titleAnimationState.get(id) === "animating") {
- titleAnimationState.set(id, "done")
+ const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
+ const currentTask = createMemo(
+ () =>
+ assistantParts().findLast(
+ (p) =>
+ p &&
+ p.type === "tool" &&
+ p.tool === "task" &&
+ p.state &&
+ "metadata" in p.state &&
+ p.state.metadata &&
+ p.state.metadata.sessionId &&
+ p.state.status === "running",
+ ) as ToolPart,
+ )
+ const resolvedParts = createMemo(() => {
+ let resolved = assistantParts()
+ const task = currentTask()
+ if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
+ const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
+ (m) => m.role === "assistant",
+ )
+ resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
}
- if (summaryAnimationState.get(id) === "animating") {
- summaryAnimationState.set(id, "done")
+ return resolved
+ })
+ const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
+ const rawStatus = createMemo(() => {
+ const last = lastPart()
+ if (!last) return undefined
+
+ if (last.type === "tool") {
+ switch (last.tool) {
+ case "task":
+ return "Delegating work"
+ case "todowrite":
+ case "todoread":
+ return "Planning next steps"
+ case "read":
+ return "Gathering context"
+ case "list":
+ case "grep":
+ case "glob":
+ return "Searching the codebase"
+ case "webfetch":
+ return "Searching the web"
+ case "edit":
+ case "write":
+ return "Making edits"
+ case "bash":
+ return "Running commands"
+ default:
+ break
+ }
+ } else if (last.type === "reasoning") {
+ return "Thinking"
+ } else if (last.type === "text") {
+ return "Gathering thoughts"
}
+ return undefined
})
- })
- const assistantMessages = createMemo(() => {
- return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
- })
- const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
- const parts = createMemo(() => data.store.part[msg().id])
- const lastTextPart = createMemo(() =>
- assistantMessageParts()
- .filter((p) => p?.type === "text")
- ?.at(-1),
- )
- const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool"))
- const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
- const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
- const [completed, setCompleted] = createSignal(initialCompleted)
- const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
- const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
+ function duration() {
+ const completed = lastAssistantMessage()?.time.completed
+ const from = DateTime.fromMillis(message()!.time.created)
+ const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
+ const interval = Interval.fromDateTimes(from, to)
+ const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
- // Should animate: state is "empty" AND value now exists, or state is "animating"
- // Transition: empty -> animating -> done (done happens on cleanup)
- const animateTitle = createMemo(() => {
- const id = msg().id
- const state = titleAnimationState.get(id)
- const title = msg().summary?.title
- if (state === "animating") {
- return true
- }
- if (state === "empty" && title) {
- titleAnimationState.set(id, "animating")
- return true
- }
- return false
- })
- const animateSummary = createMemo(() => {
- const id = msg().id
- const state = summaryAnimationState.get(id)
- const value = summary()
- if (state === "animating") {
- return true
- }
- if (state === "empty" && value) {
- summaryAnimationState.set(id, "animating")
- return true
+ return interval.toDuration(unit).normalize().toHuman({
+ notation: "compact",
+ unitDisplay: "narrow",
+ compactDisplay: "short",
+ showZeros: false,
+ })
}
- return false
- })
- createEffect(() => {
- const done = !messageWorking()
- setTimeout(() => setCompleted(done), 1200)
- })
+ const [store, setStore] = createStore({
+ status: rawStatus(),
+ stepsExpanded: true,
+ duration: duration(),
+ })
- return (
- <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
- {/* Title */}
- <div data-slot="session-turn-message-header">
- <div data-slot="session-turn-message-title">
- <Show
- when={!animateTitle()}
- fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
- >
- <h1>{msg().summary?.title}</h1>
- </Show>
- </div>
- </div>
- <div data-slot="session-turn-message-content">
- <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
- </div>
- {/* Summary */}
- <Show when={completed()}>
- <div data-slot="session-turn-summary-section">
- <div data-slot="session-turn-summary-header">
- <h2 data-slot="session-turn-summary-title">
+ createEffect(() => {
+ const timer = setInterval(() => {
+ setStore("duration", duration())
+ }, 1000)
+ onCleanup(() => clearInterval(timer))
+ })
+
+ let lastStatusChange = Date.now()
+ let statusTimeout: number | undefined
+ createEffect(() => {
+ const newStatus = rawStatus()
+ if (newStatus === store.status || !newStatus) return
+
+ const timeSinceLastChange = Date.now() - lastStatusChange
+
+ if (timeSinceLastChange >= 2500) {
+ setStore("status", newStatus)
+ lastStatusChange = Date.now()
+ if (statusTimeout) {
+ clearTimeout(statusTimeout)
+ statusTimeout = undefined
+ }
+ } else {
+ if (statusTimeout) clearTimeout(statusTimeout)
+ statusTimeout = setTimeout(() => {
+ setStore("status", rawStatus())
+ lastStatusChange = Date.now()
+ statusTimeout = undefined
+ }, 2500 - timeSinceLastChange) as unknown as number
+ }
+ })
+
+ createEffect((prev) => {
+ const isWorking = working()
+ if (prev && !isWorking && !userScrolled()) {
+ setStore("stepsExpanded", false)
+ }
+ return isWorking
+ }, working())
+
+ return (
+ <div
+ data-message={message().id}
+ data-slot="session-turn-message-container"
+ class={props.classes?.container}
+ style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }}
+ >
+ {/* Sticky Header */}
+ <div ref={setStickyHeaderRef} data-slot="session-turn-sticky-header">
+ <div data-slot="session-turn-message-header">
+ <div data-slot="session-turn-message-title">
<Switch>
- <Match when={msg().summary?.diffs?.length}>Summary</Match>
- <Match when={true}>Response</Match>
+ <Match when={working()}>
+ <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
+ </Match>
+ <Match when={true}>
+ <h1>{message().summary?.title}</h1>
+ </Match>
</Switch>
- </h2>
- <Show when={summary()}>
- {(summary) => (
- <Markdown
- data-slot="session-turn-markdown"
- data-diffs={!!msg().summary?.diffs?.length}
- data-fade={!msg().summary?.diffs?.length && animateSummary()}
- text={summary()}
- />
- )}
+ </div>
+ </div>
+ <div data-slot="session-turn-message-content">
+ <Message message={message()} parts={parts()} />
+ </div>
+ <div data-slot="session-turn-response-trigger">
+ <Button
+ data-slot="session-turn-collapsible-trigger-content"
+ variant="ghost"
+ size="small"
+ onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
+ >
+ <Show when={working()}>
+ <Spinner />
+ </Show>
+ <Switch>
+ <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
+ <Match when={store.stepsExpanded}>Hide steps</Match>
+ <Match when={!store.stepsExpanded}>Show steps</Match>
+ </Switch>
+ <span>ยท</span>
+ <span>{store.duration}</span>
+ <Icon name="chevron-grabber-vertical" size="small" />
+ </Button>
+ </div>
+ </div>
+ {/* Response */}
+ <Show when={store.stepsExpanded}>
+ <div data-slot="session-turn-collapsible-content-inner">
+ <For each={assistantMessages()}>
+ {(assistantMessage) => {
+ const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
+ const last = createMemo(() =>
+ parts()
+ .filter((p) => p?.type === "text")
+ .at(-1),
+ )
+ return (
+ <Switch>
+ <Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
+ <Message
+ message={assistantMessage}
+ parts={parts().filter((p) => p?.id !== last()?.id)}
+ />
+ </Match>
+ <Match when={true}>
+ <Message message={assistantMessage} parts={parts()} />
+ </Match>
+ </Switch>
+ )
+ }}
+ </For>
+ <Show when={error()}>
+ <Card variant="error" class="error-card">
+ {error()?.data?.message as string}
+ </Card>
</Show>
</div>
- <Accordion data-slot="session-turn-accordion" multiple>
- <For each={msg().summary?.diffs ?? []}>
- {(diff) => (
- <Accordion.Item value={diff.file}>
- <StickyAccordionHeader>
- <Accordion.Trigger>
- <div data-slot="session-turn-accordion-trigger-content">
- <div data-slot="session-turn-file-info">
- <FileIcon
- node={{ path: diff.file, type: "file" }}
- data-slot="session-turn-file-icon"
- />
- <div data-slot="session-turn-file-path">
- <Show when={diff.file.includes("/")}>
- <span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
- </Show>
- <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
+ </Show>
+ {/* Summary */}
+ <Show when={!working()}>
+ <div data-slot="session-turn-summary-section">
+ <div data-slot="session-turn-summary-header">
+ <h2 data-slot="session-turn-summary-title">
+ <Switch>
+ <Match when={message().summary?.diffs?.length}>Summary</Match>
+ <Match when={true}>Response</Match>
+ </Switch>
+ </h2>
+ <Show when={summary()}>
+ {(summary) => (
+ <Markdown
+ data-slot="session-turn-markdown"
+ data-diffs={!!message().summary?.diffs?.length}
+ text={summary()}
+ />
+ )}
+ </Show>
+ </div>
+ <Accordion data-slot="session-turn-accordion" multiple>
+ <For each={message().summary?.diffs ?? []}>
+ {(diff) => (
+ <Accordion.Item value={diff.file}>
+ <StickyAccordionHeader>
+ <Accordion.Trigger>
+ <div data-slot="session-turn-accordion-trigger-content">
+ <div data-slot="session-turn-file-info">
+ <FileIcon
+ node={{ path: diff.file, type: "file" }}
+ data-slot="session-turn-file-icon"
+ />
+ <div data-slot="session-turn-file-path">
+ <Show when={diff.file.includes("/")}>
+ <span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
+ </Show>
+ <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
+ </div>
+ </div>
+ <div data-slot="session-turn-accordion-actions">
+ <DiffChanges changes={diff} />
+ <Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
- <div data-slot="session-turn-accordion-actions">
- <DiffChanges changes={diff} />
- <Icon name="chevron-grabber-vertical" size="small" />
- </div>
- </div>
- </Accordion.Trigger>
- </StickyAccordionHeader>
- <Accordion.Content data-slot="session-turn-accordion-content">
- <Dynamic
- component={diffComponent}
- before={{
- name: diff.file!,
- contents: diff.before!,
- cacheKey: checksum(diff.before!),
- }}
- after={{
- name: diff.file!,
- contents: diff.after!,
- cacheKey: checksum(diff.after!),
- }}
- />
- </Accordion.Content>
- </Accordion.Item>
- )}
- </For>
- </Accordion>
- </div>
- </Show>
- <Show when={error() && !detailsExpanded()}>
- <Card variant="error" class="error-card">
- {error()?.data?.message as string}
- </Card>
- </Show>
- {/* Response */}
- <div data-slot="session-turn-response-section">
- <Switch>
- <Match when={!completed()}>
- <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
- </Match>
- <Match when={completed() && hasToolPart()}>
- <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
- <Collapsible.Trigger>
- <div data-slot="session-turn-collapsible-trigger-content">
- <div data-slot="session-turn-details-text">
- <Switch>
- <Match when={detailsExpanded()}>Hide details</Match>
- <Match when={!detailsExpanded()}>Show details</Match>
- </Switch>
- </div>
- <Collapsible.Arrow />
- </div>
- </Collapsible.Trigger>
- <Collapsible.Content>
- <div data-slot="session-turn-collapsible-content-inner">
- <For each={assistantMessages()}>
- {(assistantMessage) => {
- const parts = createMemo(() => data.store.part[assistantMessage.id])
- const last = createMemo(() =>
- parts()
- .filter((p) => p?.type === "text")
- .at(-1),
- )
- if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
- return (
- <Message
- message={assistantMessage}
- parts={parts().filter((p) => p?.id !== last()?.id)}
- sanitize={sanitizer()}
- />
- )
- }
- return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
- }}
- </For>
- <Show when={error()}>
- <Card variant="error" class="error-card">
- {error()?.data?.message as string}
- </Card>
- </Show>
- </div>
- </Collapsible.Content>
- </Collapsible>
- </Match>
- </Switch>
+ </Accordion.Trigger>
+ </StickyAccordionHeader>
+ <Accordion.Content data-slot="session-turn-accordion-content">
+ <Dynamic
+ component={diffComponent}
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ cacheKey: checksum(diff.before!),
+ }}
+ after={{
+ name: diff.file!,
+ contents: diff.after!,
+ cacheKey: checksum(diff.after!),
+ }}
+ />
+ </Accordion.Content>
+ </Accordion.Item>
+ )}
+ </For>
+ </Accordion>
+ </div>
+ </Show>
+ <Show when={error() && !store.stepsExpanded}>
+ <Card variant="error" class="error-card">
+ {error()?.data?.message as string}
+ </Card>
+ </Show>
</div>
- </div>
- )
- }}
- </Show>
- {props.children}
+ )
+ }}
+ </Show>
+ {props.children}
+ </div>
</div>
</div>
)
diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx
index 5e787d86b..41f4d9e71 100644
--- a/packages/ui/src/components/spinner.tsx
+++ b/packages/ui/src/components/spinner.tsx
@@ -1,14 +1,16 @@
import { ComponentProps, For } from "solid-js"
-export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
- const squares = Array.from({ length: 16 }, (_, i) => ({
- id: i,
- x: (i % 4) * 4,
- y: Math.floor(i / 4) * 4,
- delay: Math.random() * 3,
- duration: 2 + Math.random() * 2,
- }))
+const outerIndices = new Set([0, 1, 2, 3, 4, 7, 8, 11, 12, 13, 14, 15])
+const squares = Array.from({ length: 16 }, (_, i) => ({
+ id: i,
+ x: (i % 4) * 4,
+ y: Math.floor(i / 4) * 4,
+ delay: Math.random() * 1.5,
+ duration: 1 + Math.random() * 1,
+ outer: outerIndices.has(i),
+}))
+export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
return (
<svg
viewBox="0 0 15 15"
@@ -28,7 +30,7 @@ export function Spinner(props: { class?: string; classList?: ComponentProps<"div
height="3"
rx="1"
style={{
- animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
+ animation: `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`,
"animation-delay": `${square.delay}s`,
}}
/>
diff --git a/packages/ui/src/components/typewriter.tsx b/packages/ui/src/components/typewriter.tsx
index 2f6ecb016..16c85a110 100644
--- a/packages/ui/src/components/typewriter.tsx
+++ b/packages/ui/src/components/typewriter.tsx
@@ -1,4 +1,4 @@
-import { createEffect, Show, type ValidComponent } from "solid-js"
+import { createEffect, onCleanup, Show, type ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
@@ -14,6 +14,7 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
if (!text) return
let i = 0
+ const timeouts: ReturnType<typeof setTimeout>[] = []
setStore("typing", true)
setStore("displayed", "")
setStore("cursor", true)
@@ -29,14 +30,18 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
if (i < text.length) {
setStore("displayed", text.slice(0, i + 1))
i++
- setTimeout(type, getTypingDelay())
+ timeouts.push(setTimeout(type, getTypingDelay()))
} else {
setStore("typing", false)
- setTimeout(() => setStore("cursor", false), 2000)
+ timeouts.push(setTimeout(() => setStore("cursor", false), 2000))
}
}
- setTimeout(type, 200)
+ timeouts.push(setTimeout(type, 200))
+
+ onCleanup(() => {
+ for (const timeout of timeouts) clearTimeout(timeout)
+ })
})
return (
diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css
index 5fcebb93f..0ae3493eb 100644
--- a/packages/ui/src/styles/animations.css
+++ b/packages/ui/src/styles/animations.css
@@ -12,6 +12,16 @@
}
}
+@keyframes pulse-opacity-dim {
+ 0%,
+ 100% {
+ opacity: 0;
+ }
+ 50% {
+ opacity: 0.3;
+ }
+}
+
@keyframes fadeUp {
from {
opacity: 0;
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index d60082d93..ba2c954bc 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -26,7 +26,6 @@
@import "../components/logo.css" layer(components);
@import "../components/markdown.css" layer(components);
@import "../components/message-part.css" layer(components);
-@import "../components/message-progress.css" layer(components);
@import "../components/message-nav.css" layer(components);
@import "../components/progress-circle.css" layer(components);
@import "../components/resize-handle.css" layer(components);
diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css
index 98450ff53..2a095b436 100644
--- a/packages/ui/src/styles/theme.css
+++ b/packages/ui/src/styles/theme.css
@@ -122,7 +122,7 @@
--surface-diff-hidden-weaker: var(--blue-light-1);
--surface-diff-hidden-strong: var(--blue-light-5);
--surface-diff-hidden-stronger: var(--blue-light-9);
- --surface-diff-add-base: #DAFBE0;
+ --surface-diff-add-base: #dafbe0;
--surface-diff-add-weak: var(--mint-light-2);
--surface-diff-add-weaker: var(--mint-light-1);
--surface-diff-add-strong: var(--mint-light-5);
@@ -269,21 +269,21 @@
--syntax-regexp: var(--text-base);
--syntax-string: #006656;
--syntax-keyword: var(--text-weak);
- --syntax-primitive: #FB4804;
+ --syntax-primitive: #fb4804;
--syntax-operator: var(--text-base);
--syntax-variable: var(--text-strong);
- --syntax-property: #ED6DC8;
+ --syntax-property: #ed6dc8;
--syntax-type: #596600;
- --syntax-constant: #007B80;
+ --syntax-constant: #007b80;
--syntax-punctuation: var(--text-base);
--syntax-object: var(--text-strong);
--syntax-success: var(--apple-light-10);
--syntax-warning: var(--amber-light-10);
--syntax-critical: var(--ember-light-10);
- --syntax-info: #0092A8;
+ --syntax-info: #0092a8;
--syntax-diff-add: var(--mint-light-11);
--syntax-diff-delete: var(--ember-light-11);
- --syntax-diff-unknown: #FF0000;
+ --syntax-diff-unknown: #ff0000;
--markdown-heading: #d68c27;
--markdown-text: #1a1a1a;
--markdown-link: #3b7dd8;