summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-11-22 18:17:45 -0600
committerAdam <[email protected]>2025-11-22 18:19:02 -0600
commit6173b69a8b4af8e11498bbf203ffda0247da3196 (patch)
tree53c70e2514dd325d89e427d10dec02cd6077a3c1 /packages/ui/src
parentfc72cfe784433293b50f7fd771bc3a1b5a1c8f9a (diff)
downloadopencode-6173b69a8b4af8e11498bbf203ffda0247da3196.tar.gz
opencode-6173b69a8b4af8e11498bbf203ffda0247da3196.zip
wip(share): more styling
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/message-nav.css95
-rw-r--r--packages/ui/src/components/message-nav.tsx66
-rw-r--r--packages/ui/src/components/message-progress.tsx1
-rw-r--r--packages/ui/src/components/session-timeline.css324
-rw-r--r--packages/ui/src/components/session-timeline.tsx289
-rw-r--r--packages/ui/src/components/session-turn.css220
-rw-r--r--packages/ui/src/components/session-turn.tsx220
-rw-r--r--packages/ui/src/styles/index.css4
8 files changed, 604 insertions, 615 deletions
diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css
new file mode 100644
index 000000000..6e9d96a26
--- /dev/null
+++ b/packages/ui/src/components/message-nav.css
@@ -0,0 +1,95 @@
+[data-component="message-nav"] {
+ /* margin-right: 32px; */
+ /* margin-top: 12px; */
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ padding-left: 0;
+ list-style: none;
+
+ &[data-size="normal"] {
+ position: absolute;
+ right: 100%;
+ width: 240px;
+ /* margin-top: 12px; */
+
+ @media (min-width: 80rem) {
+ gap: 8px;
+ /* margin-top: 4px; */
+ }
+ }
+}
+
+[data-slot="message-nav-item"] {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ justify-content: flex-end;
+
+ [data-component="message-nav"][data-size="normal"] & {
+ justify-content: flex-start;
+ }
+}
+
+[data-slot="message-nav-tick-button"] {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ height: 8px;
+ width: 32px;
+ /* margin-right: -12px; */
+ cursor: pointer;
+ border: none;
+ background: none;
+ padding: 0;
+
+ &[data-active] [data-slot="message-nav-tick-line"] {
+ background-color: var(--icon-strong-base);
+ width: 100%;
+ }
+}
+
+[data-slot="message-nav-tick-line"] {
+ height: 1px;
+ width: 20px;
+ background-color: var(--icon-base);
+ transition:
+ width 0.2s,
+ background-color 0.2s;
+}
+
+[data-slot="message-nav-tick-button"]:hover [data-slot="message-nav-tick-line"] {
+ width: 100%;
+ background-color: var(--icon-strong-base);
+}
+
+[data-slot="message-nav-message-button"] {
+ display: flex;
+ align-items: center;
+ align-self: stretch;
+ width: 100%;
+ column-gap: 8px;
+ cursor: default;
+ border: none;
+ background: none;
+ padding: 0;
+}
+
+[data-slot="message-nav-title-preview"] {
+ font-size: 14px; /* text-14-regular */
+ color: var(--text-weak);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ text-align: left;
+
+ &[data-active] {
+ color: var(--text-strong);
+ }
+}
+
+[data-slot="message-nav-item"]:hover [data-slot="message-nav-title-preview"] {
+ color: var(--text-base);
+}
diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx
new file mode 100644
index 000000000..8475c3206
--- /dev/null
+++ b/packages/ui/src/components/message-nav.tsx
@@ -0,0 +1,66 @@
+import { UserMessage } from "@opencode-ai/sdk"
+import { ComponentProps, createMemo, For, Match, Show, splitProps, Switch } from "solid-js"
+import { DiffChanges } from "./diff-changes"
+import { Spinner } from "./spinner"
+
+export function MessageNav(
+ props: ComponentProps<"ul"> & {
+ messages: UserMessage[]
+ current?: UserMessage
+ size: "normal" | "compact"
+ working?: boolean
+ onMessageSelect: (message: UserMessage) => void
+ },
+) {
+ const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"])
+ const lastUserMessage = createMemo(() => {
+ return local.messages?.at(0)
+ })
+
+ return (
+ <ul role="list" data-component="message-nav" data-size={local.size} {...others}>
+ <For each={local.messages}>
+ {(message) => {
+ const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working)
+ const handleClick = () => local.onMessageSelect(message)
+
+ return (
+ <li data-slot="message-nav-item">
+ <Switch>
+ <Match when={local.size === "compact"}>
+ <button
+ data-slot="message-nav-tick-button"
+ data-active={message.id === local.current?.id || undefined}
+ onClick={handleClick}
+ >
+ <div data-slot="message-nav-tick-line" />
+ </button>
+ </Match>
+ <Match when={local.size === "normal"}>
+ <button data-slot="message-nav-message-button" onClick={handleClick}>
+ <Switch>
+ <Match when={messageWorking()}>
+ <Spinner />
+ </Match>
+ <Match when={true}>
+ <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+ </Match>
+ </Switch>
+ <div
+ data-slot="message-nav-title-preview"
+ data-active={message.id === local.current?.id || undefined}
+ >
+ <Show when={message.summary?.title} fallback="New message">
+ {message.summary?.title}
+ </Show>
+ </div>
+ </button>
+ </Match>
+ </Switch>
+ </li>
+ )
+ }}
+ </For>
+ </ul>
+ )
+}
diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx
index 48a234535..ca42d26ec 100644
--- a/packages/ui/src/components/message-progress.tsx
+++ b/packages/ui/src/components/message-progress.tsx
@@ -3,7 +3,6 @@ import { Part } from "./message-part"
import { Spinner } from "./spinner"
import { useData } from "../context/data"
import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk"
-import "./message-progress.css"
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[]; done?: boolean }) {
const data = useData()
diff --git a/packages/ui/src/components/session-timeline.css b/packages/ui/src/components/session-timeline.css
deleted file mode 100644
index e86e80e6e..000000000
--- a/packages/ui/src/components/session-timeline.css
+++ /dev/null
@@ -1,324 +0,0 @@
-[data-component="session-timeline"] {
- /* flex: 1; */
- min-height: 0;
- display: flex;
- align-items: flex-start;
- justify-content: flex-start;
-
- [data-slot="session-timeline-timeline-list"] {
- margin-right: 32px;
- flex-shrink: 0;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- margin-top: 12px;
-
- &[data-expanded="true"] {
- position: absolute;
- right: 100%;
- width: 240px;
- margin-top: 12px;
-
- @media (min-width: 80rem) {
- gap: 8px;
- margin-top: 4px;
- }
- }
- }
-
- [data-slot="session-timeline-timeline-item"] {
- display: flex;
- align-items: center;
- align-self: stretch;
- justify-content: flex-end;
-
- &[data-expanded="true"] {
- @media (min-width: 80rem) {
- justify-content: flex-start;
- }
- }
- }
-
- [data-slot="session-timeline-tick-button"] {
- display: flex;
- align-items: center;
- justify-content: flex-start;
- height: 8px;
- width: 32px;
- margin-right: -12px;
- cursor: pointer;
- border: none;
- background: none;
- padding: 0;
-
- &[data-active="true"] [data-slot="session-timeline-tick-line"] {
- background-color: var(--icon-strong-base);
- width: 100%;
- }
-
- &[data-expanded="true"] {
- @media (min-width: 80rem) {
- display: none;
- }
- }
- }
-
- [data-slot="session-timeline-tick-line"] {
- height: 1px;
- width: 20px;
- background-color: var(--icon-base);
- transition:
- width 0.2s,
- background-color 0.2s;
- }
-
- [data-slot="session-timeline-tick-button"]:hover [data-slot="session-timeline-tick-line"] {
- width: 100%;
- background-color: var(--icon-strong-base);
- }
-
- [data-slot="session-timeline-message-button"] {
- display: none;
- align-items: center;
- align-self: stretch;
- width: 100%;
- column-gap: 8px;
- cursor: default;
- border: none;
- background: none;
- padding: 0;
-
- &[data-expanded="true"] {
- @media (min-width: 80rem) {
- display: flex;
- }
- }
- }
-
- [data-slot="session-timeline-message-title-preview"] {
- font-size: 14px; /* text-14-regular */
- color: var(--text-weak);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
- text-align: left;
-
- &[data-active="true"] {
- color: var(--text-strong);
- }
- }
-
- [data-slot="session-timeline-timeline-item"]:hover [data-slot="session-timeline-message-title-preview"] {
- color: var(--text-base);
- }
-
- [data-slot="session-timeline-content"] {
- flex-grow: 1;
- width: 100%;
- height: 100%;
- min-width: 0;
- overflow-y: auto;
- scrollbar-width: none;
- }
-
- [data-slot="session-timeline-content"]::-webkit-scrollbar {
- display: none;
- }
-
- [data-slot="session-timeline-message-container"] {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- align-self: stretch;
- gap: 32px;
- }
-
- [data-slot="session-timeline-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-timeline-message-content"] {
- margin-top: -24px;
- }
-
- [data-slot="session-timeline-message-title"] {
- width: 100%;
- font-size: 14px; /* text-14-medium */
- font-weight: 500;
- color: var(--text-strong);
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
- white-space: nowrap;
- }
-
- [data-slot="session-timeline-message-title"] h1 {
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
- white-space: nowrap;
- font-size: inherit;
- font-weight: inherit;
- }
-
- [data-slot="session-timeline-typewriter"] {
- overflow: hidden;
- text-overflow: ellipsis;
- min-width: 0;
- white-space: nowrap;
- }
-
- [data-slot="session-timeline-summary-section"] {
- width: 100%;
- display: flex;
- flex-direction: column;
- gap: 24px;
- align-items: flex-start;
- align-self: stretch;
- }
-
- [data-slot="session-timeline-summary-header"] {
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- gap: 4px;
- align-self: stretch;
- }
-
- [data-slot="session-timeline-summary-title"] {
- font-size: 12px; /* text-12-medium */
- font-weight: 500;
- color: var(--text-weak);
- }
-
- [data-slot="session-timeline-markdown"] {
- &[data-diffs="true"] {
- font-size: 14px; /* text-14-regular */
- }
-
- &[data-fade="true"] > * {
- animation: fade-up-text 0.3s ease-out forwards;
- }
- }
-
- [data-slot="session-timeline-accordion"] {
- width: 100%;
- }
-
- [data-component="sticky-accordion-header"] {
- top: 40px;
-
- &[data-expanded]::before {
- top: -40px;
- }
- }
-
- [data-slot="session-timeline-accordion-trigger-content"] {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- gap: 20px;
- }
-
- [data-slot="session-timeline-file-info"] {
- flex-grow: 1;
- display: flex;
- align-items: center;
- gap: 20px;
- min-width: 0;
- }
-
- [data-slot="session-timeline-file-icon"] {
- flex-shrink: 0;
- width: 16px;
- height: 16px;
- }
-
- [data-slot="session-timeline-file-path"] {
- display: flex;
- flex-grow: 1;
- min-width: 0;
- }
-
- [data-slot="session-timeline-directory"] {
- color: var(--text-base);
- text-overflow: ellipsis;
- overflow: hidden;
- white-space: nowrap;
- direction: rtl;
- text-align: left;
- }
-
- [data-slot="session-timeline-filename"] {
- color: var(--text-strong);
- flex-shrink: 0;
- }
-
- [data-slot="session-timeline-accordion-actions"] {
- flex-shrink: 0;
- display: flex;
- gap: 16px;
- align-items: center;
- justify-content: flex-end;
- }
-
- [data-slot="session-timeline-accordion-content"] {
- max-height: 240px; /* max-h-60 */
- overflow-y: auto;
- scrollbar-width: none;
- }
-
- [data-slot="session-timeline-accordion-content"]::-webkit-scrollbar {
- display: none;
- }
-
- [data-slot="session-timeline-response-section"] {
- width: 100%;
- }
-
- [data-slot="session-timeline-collapsible-trigger-content"] {
- color: var(--text-weak);
- cursor: pointer;
- background: none;
- border: none;
- padding: 0;
- display: flex;
- align-items: center;
-
- &:hover {
- color: var(--text-strong);
- }
- display: flex;
- align-items: center;
- gap: 4px;
- align-self: stretch;
- }
-
- [data-slot="session-timeline-details-text"] {
- font-size: 12px; /* text-12-medium */
- font-weight: 500;
- }
-
- .error-card {
- color: var(--text-on-critical-base);
- }
-
- [data-slot="session-timeline-collapsible-content-inner"] {
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
- align-self: stretch;
- gap: 12px;
- }
-}
diff --git a/packages/ui/src/components/session-timeline.tsx b/packages/ui/src/components/session-timeline.tsx
deleted file mode 100644
index 5d451b39d..000000000
--- a/packages/ui/src/components/session-timeline.tsx
+++ /dev/null
@@ -1,289 +0,0 @@
-import { AssistantMessage } from "@opencode-ai/sdk"
-import { useData } from "../context"
-import { Binary } from "@opencode-ai/util/binary"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch } from "solid-js"
-import { createStore } from "solid-js/store"
-import { DiffChanges } from "./diff-changes"
-import { Spinner } from "./spinner"
-import { Typewriter } from "./typewriter"
-import { Message } from "./message-part"
-import { Markdown } from "./markdown"
-import { Accordion } from "./accordion"
-import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { FileIcon } from "./file-icon"
-import { Icon } from "./icon"
-import { Diff } from "./diff"
-import { Card } from "./card"
-import { MessageProgress } from "./message-progress"
-import { Collapsible } from "./collapsible"
-
-export function SessionTimeline(
- props: ParentProps<{
- sessionID: string
- classes?: {
- root?: string
- content?: string
- container?: string
- }
- expanded?: boolean
- }>,
-) {
- const data = useData()
- const [store, setStore] = createStore({
- messageId: undefined as string | undefined,
- })
- const match = Binary.search(data.session, props.sessionID, (s) => s.id)
- if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
-
- // const info = createMemo(() => data.session[match.index])
- const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
- const userMessages = createMemo(() =>
- messages()
- .filter((m) => m.role === "user")
- .sort((a, b) => b.id.localeCompare(a.id)),
- )
- const lastUserMessage = createMemo(() => {
- return userMessages()?.at(0)
- })
- const activeMessage = createMemo(() => {
- if (!store.messageId) return lastUserMessage()
- return userMessages()?.find((m) => m.id === store.messageId)
- })
- const status = createMemo(
- () =>
- data.session_status[props.sessionID] ?? {
- type: "idle",
- },
- )
- const working = createMemo(() => status()?.type !== "idle")
-
- return (
- <div data-component="session-timeline" class={props.classes?.root}>
- <Show when={userMessages().length > 1}>
- <ul role="list" data-slot="session-timeline-timeline-list" data-expanded={props.expanded}>
- <For each={userMessages()}>
- {(message) => {
- const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working())
- const handleClick = () => setStore("messageId", message.id)
-
- return (
- <li data-slot="session-timeline-timeline-item" data-expanded={props.expanded}>
- <button
- data-slot="session-timeline-tick-button"
- data-active={activeMessage()?.id === message.id}
- data-expanded={props.expanded}
- onClick={handleClick}
- >
- <div data-slot="session-timeline-tick-line" />
- </button>
- <button
- data-slot="session-timeline-message-button"
- data-expanded={props.expanded}
- onClick={handleClick}
- >
- <Switch>
- <Match when={messageWorking()}>
- <Spinner />
- </Match>
- <Match when={true}>
- <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
- </Match>
- </Switch>
- <div
- data-slot="session-timeline-message-title-preview"
- data-active={activeMessage()?.id === message.id}
- >
- <Show when={message.summary?.title} fallback="New message">
- {message.summary?.title}
- </Show>
- </div>
- </button>
- </li>
- )
- }}
- </For>
- </ul>
- </Show>
- <div data-slot="session-timeline-content" class={props.classes?.content}>
- <For each={userMessages()}>
- {(message) => {
- const isActive = createMemo(() => activeMessage()?.id === message.id)
- const titleSeen = createMemo(() => true)
- const contentSeen = createMemo(() => true)
- {
- /* const titleSeen = createSeen(`message-title-${message.id}`) */
- }
- {
- /* const contentSeen = createSeen(`message-content-${message.id}`) */
- }
- const [titled, setTitled] = createSignal(titleSeen())
- const assistantMessages = createMemo(() => {
- return messages()?.filter((m) => m.role === "assistant" && m.parentID == message.id) as AssistantMessage[]
- })
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
- const [detailsExpanded, setDetailsExpanded] = createSignal(false)
- const parts = createMemo(() => data.part[message.id])
- const hasToolPart = createMemo(() =>
- assistantMessages()
- ?.flatMap((m) => data.part[m.id])
- .some((p) => p?.type === "tool"),
- )
- const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working())
- const initialCompleted = !(message.id === lastUserMessage()?.id && working())
- const [completed, setCompleted] = createSignal(initialCompleted)
-
- // allowing time for the animations to finish
- createEffect(() => {
- if (titleSeen()) return
- const title = message.summary?.title
- if (title) setTimeout(() => setTitled(true), 10_000)
- })
- createEffect(() => {
- const completed = !messageWorking()
- setTimeout(() => setCompleted(completed), 1200)
- })
-
- return (
- <Show when={isActive()}>
- <div
- data-message={message.id}
- data-slot="session-timeline-message-container"
- class={props.classes?.container}
- >
- {/* Title */}
- <div data-slot="session-timeline-message-header">
- <div data-slot="session-timeline-message-title">
- <Show
- when={titled()}
- fallback={
- <Typewriter as="h1" text={message.summary?.title} data-slot="session-timeline-typewriter" />
- }
- >
- <h1>{message.summary?.title}</h1>
- </Show>
- </div>
- </div>
- <div data-slot="session-timeline-message-content">
- <Message message={message} parts={parts()} />
- </div>
- {/* Summary */}
- <Show when={completed()}>
- <div data-slot="session-timeline-summary-section">
- <div data-slot="session-timeline-summary-header">
- <h2 data-slot="session-timeline-summary-title">
- <Switch>
- <Match when={message.summary?.diffs?.length}>Summary</Match>
- <Match when={true}>Response</Match>
- </Switch>
- </h2>
- <Show when={message.summary?.body}>
- {(summary) => (
- <Markdown
- data-slot="session-timeline-markdown"
- data-diffs={!!message.summary?.diffs?.length}
- data-fade={!message.summary?.diffs?.length && !contentSeen()}
- text={summary()}
- />
- )}
- </Show>
- </div>
- <Accordion data-slot="session-timeline-accordion" multiple>
- <For each={message.summary?.diffs ?? []}>
- {(diff) => (
- <Accordion.Item value={diff.file}>
- <StickyAccordionHeader>
- <Accordion.Trigger>
- <div data-slot="session-timeline-accordion-trigger-content">
- <div data-slot="session-timeline-file-info">
- <FileIcon
- node={{ path: diff.file, type: "file" }}
- data-slot="session-timeline-file-icon"
- />
- <div data-slot="session-timeline-file-path">
- <Show when={diff.file.includes("/")}>
- <span data-slot="session-timeline-directory">
- {getDirectory(diff.file)}&lrm;
- </span>
- </Show>
- <span data-slot="session-timeline-filename">{getFilename(diff.file)}</span>
- </div>
- </div>
- <div data-slot="session-timeline-accordion-actions">
- <DiffChanges changes={diff} />
- <Icon name="chevron-grabber-vertical" size="small" />
- </div>
- </div>
- </Accordion.Trigger>
- </StickyAccordionHeader>
- <Accordion.Content data-slot="session-timeline-accordion-content">
- <Diff
- before={{
- name: diff.file!,
- contents: diff.before!,
- }}
- after={{
- name: diff.file!,
- contents: 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-timeline-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-timeline-collapsible-trigger-content">
- <div data-slot="session-timeline-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-timeline-collapsible-content-inner">
- <For each={assistantMessages()}>
- {(assistantMessage) => {
- const parts = createMemo(() => data.part[assistantMessage.id])
- return <Message message={assistantMessage} parts={parts()} />
- }}
- </For>
- <Show when={error()}>
- <Card variant="error" class="error-card">
- {error()?.data?.message as string}
- </Card>
- </Show>
- </div>
- </Collapsible.Content>
- </Collapsible>
- </Match>
- </Switch>
- </div>
- </div>
- </Show>
- )
- }}
- </For>
- {props.children}
- </div>
- </div>
- )
-}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
new file mode 100644
index 000000000..1dfb54c56
--- /dev/null
+++ b/packages/ui/src/components/session-turn.css
@@ -0,0 +1,220 @@
+[data-component="session-turn"] {
+ /* flex: 1; */
+ height: 100%;
+ min-height: 0;
+ min-width: 0;
+ display: flex;
+ align-items: flex-start;
+ justify-content: flex-start;
+
+ [data-slot="session-turn-content"] {
+ flex-grow: 1;
+ width: 100%;
+ height: 100%;
+ min-width: 0;
+ overflow-y: auto;
+ scrollbar-width: none;
+ }
+
+ [data-slot="session-turn-content"]::-webkit-scrollbar {
+ display: none;
+ }
+
+ [data-slot="session-turn-message-container"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ min-width: 0;
+ gap: 32px;
+ }
+
+ [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-title"] {
+ width: 100%;
+ font-size: 14px; /* text-14-medium */
+ font-weight: 500;
+ color: var(--text-strong);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ white-space: nowrap;
+ }
+
+ [data-slot="session-turn-message-title"] h1 {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ white-space: nowrap;
+ font-size: inherit;
+ font-weight: inherit;
+ }
+
+ [data-slot="session-turn-typewriter"] {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ white-space: nowrap;
+ }
+
+ [data-slot="session-turn-summary-section"] {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ align-items: flex-start;
+ align-self: stretch;
+ }
+
+ [data-slot="session-turn-summary-header"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ align-self: stretch;
+ }
+
+ [data-slot="session-turn-summary-title"] {
+ font-size: 12px; /* text-12-medium */
+ font-weight: 500;
+ color: var(--text-weak);
+ }
+
+ [data-slot="session-turn-markdown"] {
+ &[data-diffs="true"] {
+ font-size: 14px; /* text-14-regular */
+ }
+
+ &[data-fade="true"] > * {
+ animation: fade-up-text 0.3s ease-out forwards;
+ }
+ }
+
+ [data-slot="session-turn-accordion"] {
+ width: 100%;
+ }
+
+ [data-component="sticky-accordion-header"] {
+ top: 40px;
+
+ &[data-expanded]::before {
+ top: -40px;
+ }
+ }
+
+ [data-slot="session-turn-accordion-trigger-content"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 20px;
+ }
+
+ [data-slot="session-turn-file-info"] {
+ flex-grow: 1;
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ min-width: 0;
+ }
+
+ [data-slot="session-turn-file-icon"] {
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+ }
+
+ [data-slot="session-turn-file-path"] {
+ display: flex;
+ flex-grow: 1;
+ min-width: 0;
+ }
+
+ [data-slot="session-turn-directory"] {
+ color: var(--text-base);
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ direction: rtl;
+ text-align: left;
+ }
+
+ [data-slot="session-turn-filename"] {
+ color: var(--text-strong);
+ flex-shrink: 0;
+ }
+
+ [data-slot="session-turn-accordion-actions"] {
+ flex-shrink: 0;
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ justify-content: flex-end;
+ }
+
+ [data-slot="session-turn-accordion-content"] {
+ max-height: 240px; /* max-h-60 */
+ overflow-y: auto;
+ scrollbar-width: none;
+ }
+
+ [data-slot="session-turn-accordion-content"]::-webkit-scrollbar {
+ display: none;
+ }
+
+ [data-slot="session-turn-response-section"] {
+ width: 100%;
+ min-width: 0;
+ }
+
+ [data-slot="session-turn-collapsible-trigger-content"] {
+ color: var(--text-weak);
+ cursor: pointer;
+ background: none;
+ border: none;
+ padding: 0;
+ display: flex;
+ align-items: center;
+
+ &:hover {
+ color: var(--text-strong);
+ }
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ align-self: stretch;
+ }
+
+ [data-slot="session-turn-details-text"] {
+ font-size: 12px; /* text-12-medium */
+ font-weight: 500;
+ }
+
+ .error-card {
+ color: var(--text-on-critical-base);
+ }
+
+ [data-slot="session-turn-collapsible-content-inner"] {
+ width: 100%;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ align-self: stretch;
+ gap: 12px;
+ }
+}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
new file mode 100644
index 000000000..f92089d00
--- /dev/null
+++ b/packages/ui/src/components/session-turn.tsx
@@ -0,0 +1,220 @@
+import { AssistantMessage } from "@opencode-ai/sdk"
+import { useData } from "../context"
+import { Binary } from "@opencode-ai/util/binary"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch } from "solid-js"
+import { DiffChanges } from "./diff-changes"
+import { Typewriter } from "./typewriter"
+import { Message } from "./message-part"
+import { Markdown } from "./markdown"
+import { Accordion } from "./accordion"
+import { StickyAccordionHeader } from "./sticky-accordion-header"
+import { FileIcon } from "./file-icon"
+import { Icon } from "./icon"
+import { Diff } from "./diff"
+import { Card } from "./card"
+import { MessageProgress } from "./message-progress"
+import { Collapsible } from "./collapsible"
+
+export function SessionTurn(
+ props: ParentProps<{
+ sessionID: string
+ messageID: string
+ classes?: {
+ root?: string
+ content?: string
+ container?: string
+ }
+ }>,
+) {
+ const data = useData()
+ const match = Binary.search(data.session, props.sessionID, (s) => s.id)
+ if (!match.found) throw new Error(`Session ${props.sessionID} not found`)
+
+ const messages = createMemo(() => (props.sessionID ? (data.message[props.sessionID] ?? []) : []))
+ const userMessages = createMemo(() =>
+ messages()
+ .filter((m) => m.role === "user")
+ .sort((a, b) => b.id.localeCompare(a.id)),
+ )
+ const lastUserMessage = createMemo(() => {
+ return userMessages()?.at(0)
+ })
+ const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
+
+ const status = createMemo(
+ () =>
+ data.session_status[props.sessionID] ?? {
+ type: "idle",
+ },
+ )
+ 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 titleSeen = createMemo(() => true)
+ const contentSeen = createMemo(() => true)
+
+ const [titled, setTitled] = createSignal(titleSeen())
+ const assistantMessages = createMemo(() => {
+ return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
+ })
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+ const [detailsExpanded, setDetailsExpanded] = createSignal(false)
+ const parts = createMemo(() => data.part[msg().id])
+ const hasToolPart = createMemo(() =>
+ assistantMessages()
+ ?.flatMap((m) => data.part[m.id])
+ .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)
+
+ // allowing time for the animations to finish
+ createEffect(() => {
+ if (titleSeen()) return
+ const title = msg().summary?.title
+ if (title) setTimeout(() => setTitled(true), 10_000)
+ })
+ createEffect(() => {
+ const completed = !messageWorking()
+ setTimeout(() => setCompleted(completed), 1200)
+ })
+
+ 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={titled()}
+ 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()} />
+ </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">
+ <Switch>
+ <Match when={msg().summary?.diffs?.length}>Summary</Match>
+ <Match when={true}>Response</Match>
+ </Switch>
+ </h2>
+ <Show when={msg().summary?.body}>
+ {(summary) => (
+ <Markdown
+ data-slot="session-turn-markdown"
+ data-diffs={!!msg().summary?.diffs?.length}
+ data-fade={!msg().summary?.diffs?.length && !contentSeen()}
+ text={summary()}
+ />
+ )}
+ </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>
+ </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">
+ <Diff
+ before={{
+ name: diff.file!,
+ contents: diff.before!,
+ }}
+ after={{
+ name: diff.file!,
+ contents: 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.part[assistantMessage.id])
+ return <Message message={assistantMessage} parts={parts()} />
+ }}
+ </For>
+ <Show when={error()}>
+ <Card variant="error" class="error-card">
+ {error()?.data?.message as string}
+ </Card>
+ </Show>
+ </div>
+ </Collapsible.Content>
+ </Collapsible>
+ </Match>
+ </Switch>
+ </div>
+ </div>
+ )
+ }}
+ </Show>
+ {props.children}
+ </div>
+ </div>
+ )
+}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 3c4ccbeb9..e29d8e33b 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -22,12 +22,14 @@
@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/select.css" layer(components);
@import "../components/select-dialog.css" layer(components);
@import "../components/spinner.css" layer(components);
@import "../components/session-review.css" layer(components);
-@import "../components/session-timeline.css" layer(components);
+@import "../components/session-turn.css" layer(components);
@import "../components/sticky-accordion-header.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tooltip.css" layer(components);