diff options
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/message-nav.css | 95 | ||||
| -rw-r--r-- | packages/ui/src/components/message-nav.tsx | 66 | ||||
| -rw-r--r-- | packages/ui/src/components/message-progress.tsx | 1 | ||||
| -rw-r--r-- | packages/ui/src/components/session-timeline.css | 324 | ||||
| -rw-r--r-- | packages/ui/src/components/session-timeline.tsx | 289 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.css | 220 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 220 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 4 |
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)}‎ - </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)}‎</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); |
