From 6173b69a8b4af8e11498bbf203ffda0247da3196 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:17:45 -0600 Subject: [PATCH] wip(share): more styling --- packages/desktop/src/context/session.tsx | 6 +- packages/desktop/src/pages/session.tsx | 33 +- .../src/routes/share/[sessionID].tsx | 58 +++- packages/ui/src/components/message-nav.css | 95 +++++ packages/ui/src/components/message-nav.tsx | 66 ++++ .../ui/src/components/message-progress.tsx | 1 - .../ui/src/components/session-timeline.css | 324 ------------------ .../ui/src/components/session-timeline.tsx | 289 ---------------- packages/ui/src/components/session-turn.css | 220 ++++++++++++ packages/ui/src/components/session-turn.tsx | 220 ++++++++++++ packages/ui/src/styles/index.css | 4 +- 11 files changed, 673 insertions(+), 643 deletions(-) create mode 100644 packages/ui/src/components/message-nav.css create mode 100644 packages/ui/src/components/message-nav.tsx delete mode 100644 packages/ui/src/components/session-timeline.css delete mode 100644 packages/ui/src/components/session-timeline.tsx create mode 100644 packages/ui/src/components/session-turn.css create mode 100644 packages/ui/src/components/session-turn.tsx diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index e6b6b40da..72098a939 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -5,7 +5,7 @@ import { useSync } from "./sync" import { makePersisted } from "@solid-primitives/storage" import { TextSelection } from "./local" import { pipe, sumBy } from "remeda" -import { AssistantMessage } from "@opencode-ai/sdk" +import { AssistantMessage, UserMessage } from "@opencode-ai/sdk" import { useParams } from "@solidjs/router" import { base64Encode } from "@/utils" @@ -123,8 +123,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex user: userMessages, last: lastUserMessage, active: activeMessage, - setActive(id: string | undefined) { - setStore("messageId", id) + setActive(message: UserMessage | undefined) { + setStore("messageId", message?.id) }, }, usage: { diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index a889e680e..f2c572c3f 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { getDirectory, getFilename } from "@/utils" @@ -12,7 +12,8 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Tabs } from "@opencode-ai/ui/tabs" import { Code } from "@opencode-ai/ui/code" -import { SessionTimeline } from "@opencode-ai/ui/session-timeline" +import { SessionTurn } from "@opencode-ai/ui/session-turn" +import { MessageNav } from "@opencode-ai/ui/message-nav" import { SessionReview } from "@opencode-ai/ui/session-review" import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { @@ -255,6 +256,8 @@ export default function Page() { return typeof draggable.id === "string" ? draggable.id : undefined } + const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) + return (
-
+
- +
+ 1}> + + + +
@@ -456,7 +471,7 @@ export default function Page() { -
+
{ inputRef = el diff --git a/packages/enterprise/src/routes/share/[sessionID].tsx b/packages/enterprise/src/routes/share/[sessionID].tsx index 65d894b54..0ff0482f1 100644 --- a/packages/enterprise/src/routes/share/[sessionID].tsx +++ b/packages/enterprise/src/routes/share/[sessionID].tsx @@ -1,5 +1,5 @@ -import { FileDiff, Message, Part, Session, SessionStatus } from "@opencode-ai/sdk" -import { SessionTimeline } from "@opencode-ai/ui/session-timeline" +import { FileDiff, Message, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk" +import { SessionTurn } from "@opencode-ai/ui/session-turn" import { SessionReview } from "@opencode-ai/ui/session-review" import { DataProvider, useData } from "@opencode-ai/ui/context" import { createAsync, query, RouteDefinition, useParams } from "@solidjs/router" @@ -10,6 +10,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { iife } from "@opencode-ai/util/iife" import { Binary } from "@opencode-ai/util/binary" import { DateTime } from "luxon" +import { MessageNav } from "@opencode-ai/ui/message-nav" +import { createStore } from "solid-js/store" const getData = query(async (sessionID) => { const data = await Share.data(sessionID) @@ -40,7 +42,6 @@ const getData = query(async (sessionID) => { message: {}, part: {}, } - for (const item of data) { switch (item.type) { case "session": @@ -82,14 +83,28 @@ export default function () { {iife(() => { const data = useData() + const [store, setStore] = createStore({ + messageId: undefined as string | undefined, + }) const match = createMemo(() => Binary.search(data.session, params.sessionID!, (s) => s.id)) if (!match().found) throw new Error(`Session ${params.sessionID} not found`) const info = createMemo(() => data.session[match().index]) - const firstUserMessage = createMemo(() => - data.message[params.sessionID!]?.filter((m) => m.role === "user")?.at(0), + const messages = createMemo(() => + params.sessionID ? (data.message[params.sessionID]?.filter((m) => m.role === "user") ?? []) : [], ) - const provider = createMemo(() => firstUserMessage()?.model?.providerID) - const model = createMemo(() => firstUserMessage()?.model?.modelID) + const firstUserMessage = createMemo(() => messages().at(0)) + const activeMessage = createMemo( + () => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(), + ) + function setActiveMessage(message: UserMessage | undefined) { + if (message) { + setStore("messageId", message.id) + } else { + setStore("messageId", undefined) + } + } + const provider = createMemo(() => activeMessage()?.model?.providerID) + const model = createMemo(() => activeMessage()?.model?.modelID) const diffs = createMemo(() => data.session_diff[params.sessionID!] ?? []) return ( @@ -145,15 +160,26 @@ export default function () {
{info().title}
- -
- -
-
+
+ 1}> + + + +
+ +
+
+
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 ( +
    + + {(message) => { + const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working) + const handleClick = () => local.onMessageSelect(message) + + return ( +
  • + + + + + + + + +
  • + ) + }} +
    +
+ ) +} 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 ( -
- 1}> -
    - - {(message) => { - const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && working()) - const handleClick = () => setStore("messageId", message.id) - - return ( -
  • - - -
  • - ) - }} -
    -
-
-
- - {(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 ( - -
- {/* Title */} -
-
- - } - > -

{message.summary?.title}

-
-
-
-
- -
- {/* Summary */} - -
-
-

- - Summary - Response - -

- - {(summary) => ( - - )} - -
- - - {(diff) => ( - - - -
-
- -
- - - {getDirectory(diff.file)}‎ - - - {getFilename(diff.file)} -
-
-
- - -
-
-
-
- - - -
- )} -
-
-
-
- - - {error()?.data?.message as string} - - - {/* Response */} -
- - - - - - - -
-
- - Hide details - Show details - -
- -
-
- -
- - {(assistantMessage) => { - const parts = createMemo(() => data.part[assistantMessage.id]) - return - }} - - - - {error()?.data?.message as string} - - -
-
-
-
-
-
-
-
- ) - }} -
- {props.children} -
-
- ) -} 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 ( +
+
+ + {(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 ( +
+ {/* Title */} +
+
+ } + > +

{msg().summary?.title}

+
+
+
+
+ +
+ {/* Summary */} + +
+
+

+ + Summary + Response + +

+ + {(summary) => ( + + )} + +
+ + + {(diff) => ( + + + +
+
+ +
+ + {getDirectory(diff.file)}‎ + + {getFilename(diff.file)} +
+
+
+ + +
+
+
+
+ + + +
+ )} +
+
+
+
+ + + {error()?.data?.message as string} + + + {/* Response */} +
+ + + + + + + +
+
+ + Hide details + Show details + +
+ +
+
+ +
+ + {(assistantMessage) => { + const parts = createMemo(() => data.part[assistantMessage.id]) + return + }} + + + + {error()?.data?.message as string} + + +
+
+
+
+
+
+
+ ) + }} +
+ {props.children} +
+
+ ) +} 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);