mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
wip(share): more styling
This commit is contained in:
parent
fc72cfe784
commit
6173b69a8b
11 changed files with 673 additions and 643 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div class="relative bg-background-base size-full overflow-x-hidden">
|
||||
<DragDropProvider
|
||||
|
|
@ -330,14 +333,26 @@ export default function Page() {
|
|||
flex: layout.review.state() === "pane",
|
||||
}}
|
||||
>
|
||||
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
|
||||
<div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
|
||||
<Switch>
|
||||
<Match when={session.id}>
|
||||
<SessionTimeline
|
||||
sessionID={session.id!}
|
||||
expanded={layout.review.state() === "tab" || !session.diffs().length}
|
||||
classes={{ root: "pb-20", container: "pb-20" }}
|
||||
/>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={session.messages.user().length > 1}>
|
||||
<MessageNav
|
||||
classList={{ "mt-1.5 mr-3": wide(), "mt-3 mr-8": !wide() }}
|
||||
messages={session.messages.user()}
|
||||
current={session.messages.active()}
|
||||
onMessageSelect={session.messages.setActive}
|
||||
size={wide() ? "normal" : "compact"}
|
||||
working={session.working()}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={session.id!}
|
||||
messageID={session.messages.active()?.id!}
|
||||
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20" }}
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
|
||||
|
|
@ -456,7 +471,7 @@ export default function Page() {
|
|||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<Show when={session.layout.tabs.active}>
|
||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-6">
|
||||
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
|
||||
<PromptInput
|
||||
ref={(el) => {
|
||||
inputRef = el
|
||||
|
|
|
|||
|
|
@ -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 () {
|
|||
<DataProvider data={data()}>
|
||||
{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 () {
|
|||
</div>
|
||||
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
|
||||
</div>
|
||||
<SessionTimeline
|
||||
sessionID={params.sessionID!}
|
||||
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
|
||||
expanded
|
||||
>
|
||||
<div class="flex items-center justify-center pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</SessionTimeline>
|
||||
<div class="flex items-start justify-start h-full min-h-0">
|
||||
<Show when={messages().length > 1}>
|
||||
<MessageNav
|
||||
classList={{ "mt-3 mr-3": true }}
|
||||
messages={messages()}
|
||||
current={activeMessage()}
|
||||
onMessageSelect={setActiveMessage}
|
||||
size={!diffs().length ? "normal" : "compact"}
|
||||
/>
|
||||
</Show>
|
||||
<SessionTurn
|
||||
sessionID={params.sessionID!}
|
||||
messageID={store.messageId ?? firstUserMessage()!.id!}
|
||||
classes={{ root: "grow", content: "flex flex-col justify-between", container: "pb-20" }}
|
||||
>
|
||||
<div class="flex items-center justify-center pb-8 shrink-0">
|
||||
<Logo class="w-58.5 opacity-12" />
|
||||
</div>
|
||||
</SessionTurn>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={diffs().length}>
|
||||
<div class="relative grow px-6 pt-14 flex-1 min-h-0 border-l border-border-weak-base">
|
||||
|
|
|
|||
95
packages/ui/src/components/message-nav.css
Normal file
95
packages/ui/src/components/message-nav.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
66
packages/ui/src/components/message-nav.tsx
Normal file
66
packages/ui/src/components/message-nav.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
220
packages/ui/src/components/session-turn.css
Normal file
220
packages/ui/src/components/session-turn.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
220
packages/ui/src/components/session-turn.tsx
Normal file
220
packages/ui/src/components/session-turn.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue