|
|
@@ -1,9 +1,21 @@
|
|
|
-import { AssistantMessage } from "@opencode-ai/sdk/v2"
|
|
|
+import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client"
|
|
|
import { useData } from "../context"
|
|
|
import { useDiffComponent } from "../context/diff"
|
|
|
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
|
-import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
|
|
|
+import {
|
|
|
+ createEffect,
|
|
|
+ createMemo,
|
|
|
+ createSignal,
|
|
|
+ For,
|
|
|
+ Match,
|
|
|
+ onCleanup,
|
|
|
+ onMount,
|
|
|
+ ParentProps,
|
|
|
+ Show,
|
|
|
+ Switch,
|
|
|
+} from "solid-js"
|
|
|
+import { createResizeObserver } from "@solid-primitives/resize-observer"
|
|
|
import { DiffChanges } from "./diff-changes"
|
|
|
import { Typewriter } from "./typewriter"
|
|
|
import { Message } from "./message-part"
|
|
|
@@ -13,16 +25,11 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
|
|
|
import { FileIcon } from "./file-icon"
|
|
|
import { Icon } from "./icon"
|
|
|
import { Card } from "./card"
|
|
|
-import { MessageProgress } from "./message-progress"
|
|
|
-import { Collapsible } from "./collapsible"
|
|
|
import { Dynamic } from "solid-js/web"
|
|
|
-
|
|
|
-// Track animation state per message ID - persists across re-renders
|
|
|
-// "empty" = first saw with no value (should animate when value arrives)
|
|
|
-// "animating" = currently animating (keep returning true)
|
|
|
-// "done" = already animated or first saw with value (never animate)
|
|
|
-const titleAnimationState = new Map<string, "empty" | "animating" | "done">()
|
|
|
-const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
|
|
|
+import { Button } from "./button"
|
|
|
+import { Spinner } from "./spinner"
|
|
|
+import { createStore } from "solid-js/store"
|
|
|
+import { DateTime, DurationUnit, Interval } from "luxon"
|
|
|
|
|
|
export function SessionTurn(
|
|
|
props: ParentProps<{
|
|
|
@@ -37,18 +44,13 @@ export function SessionTurn(
|
|
|
) {
|
|
|
const data = useData()
|
|
|
const diffComponent = useDiffComponent()
|
|
|
- const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
|
|
|
const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
|
|
|
const userMessages = createMemo(() =>
|
|
|
messages()
|
|
|
.filter((m) => m.role === "user")
|
|
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
|
)
|
|
|
- const lastUserMessage = createMemo(() => {
|
|
|
- return userMessages()?.at(-1)
|
|
|
- })
|
|
|
const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
|
|
|
-
|
|
|
const status = createMemo(
|
|
|
() =>
|
|
|
data.store.session_status[props.sessionID] ?? {
|
|
|
@@ -57,241 +59,346 @@ export function SessionTurn(
|
|
|
)
|
|
|
const working = createMemo(() => status()?.type !== "idle")
|
|
|
|
|
|
- return (
|
|
|
- <div data-component="session-turn" class={props.classes?.root}>
|
|
|
- <div data-slot="session-turn-content" class={props.classes?.content}>
|
|
|
- <Show when={message()}>
|
|
|
- {(msg) => {
|
|
|
- const [detailsExpanded, setDetailsExpanded] = createSignal(false)
|
|
|
+ let scrollRef: HTMLDivElement | undefined
|
|
|
+ const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
|
|
|
+ const [stickyHeaderRef, setStickyHeaderRef] = createSignal<HTMLDivElement>()
|
|
|
+ const [userScrolled, setUserScrolled] = createSignal(false)
|
|
|
+ const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0)
|
|
|
|
|
|
- // Animation logic: only animate if we witness the value transition from empty to non-empty
|
|
|
- // Track in module-level Maps keyed by message ID so it persists across re-renders
|
|
|
+ function handleScroll() {
|
|
|
+ if (!scrollRef) return
|
|
|
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef
|
|
|
+ const atBottom = scrollHeight - scrollTop - clientHeight < 50
|
|
|
+ if (!atBottom && working()) {
|
|
|
+ setUserScrolled(true)
|
|
|
+ }
|
|
|
+ }
|
|
|
|
|
|
- // Initialize animation state for current message (reactive - runs when msg().id changes)
|
|
|
- createEffect(() => {
|
|
|
- const id = msg().id
|
|
|
- if (!titleAnimationState.has(id)) {
|
|
|
- titleAnimationState.set(id, msg().summary?.title ? "done" : "empty")
|
|
|
- }
|
|
|
- if (!summaryAnimationState.has(id)) {
|
|
|
- const assistantMsgs = messages()?.filter(
|
|
|
- (m) => m.role === "assistant" && m.parentID == id,
|
|
|
+ function handleInteraction() {
|
|
|
+ if (working()) {
|
|
|
+ setUserScrolled(true)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ if (!working()) {
|
|
|
+ setUserScrolled(false)
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ createResizeObserver(contentRef, () => {
|
|
|
+ if (!scrollRef || userScrolled() || !working()) return
|
|
|
+ scrollRef.scrollTop = scrollRef.scrollHeight
|
|
|
+ })
|
|
|
+
|
|
|
+ createResizeObserver(stickyHeaderRef, ({ height }) => {
|
|
|
+ setStickyHeaderHeight(height + 8)
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div data-component="session-turn" class={props.classes?.root}>
|
|
|
+ <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
|
|
|
+ <div ref={setContentRef} onClick={handleInteraction}>
|
|
|
+ <Show when={message()}>
|
|
|
+ {(message) => {
|
|
|
+ const assistantMessages = createMemo(() => {
|
|
|
+ return messages()?.filter(
|
|
|
+ (m) => m.role === "assistant" && m.parentID == message().id,
|
|
|
) as AssistantMessage[]
|
|
|
- const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id])
|
|
|
- const lastText = parts?.filter((p) => p?.type === "text")?.at(-1)
|
|
|
- const summaryValue = msg().summary?.body ?? lastText?.text
|
|
|
- summaryAnimationState.set(id, summaryValue ? "done" : "empty")
|
|
|
- }
|
|
|
+ })
|
|
|
+ const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
|
|
|
+ const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
|
|
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
|
|
+ const parts = createMemo(() => data.store.part[message().id])
|
|
|
+ const lastTextPart = createMemo(() =>
|
|
|
+ assistantMessageParts()
|
|
|
+ .filter((p) => p?.type === "text")
|
|
|
+ ?.at(-1),
|
|
|
+ )
|
|
|
+ const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
|
|
|
+ const lastTextPartShown = createMemo(
|
|
|
+ () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
|
|
|
+ )
|
|
|
|
|
|
- // When message changes or component unmounts, mark any "animating" states as "done"
|
|
|
- onCleanup(() => {
|
|
|
- if (titleAnimationState.get(id) === "animating") {
|
|
|
- titleAnimationState.set(id, "done")
|
|
|
+ const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
|
|
|
+ const currentTask = createMemo(
|
|
|
+ () =>
|
|
|
+ assistantParts().findLast(
|
|
|
+ (p) =>
|
|
|
+ p &&
|
|
|
+ p.type === "tool" &&
|
|
|
+ p.tool === "task" &&
|
|
|
+ p.state &&
|
|
|
+ "metadata" in p.state &&
|
|
|
+ p.state.metadata &&
|
|
|
+ p.state.metadata.sessionId &&
|
|
|
+ p.state.status === "running",
|
|
|
+ ) as ToolPart,
|
|
|
+ )
|
|
|
+ const resolvedParts = createMemo(() => {
|
|
|
+ let resolved = assistantParts()
|
|
|
+ const task = currentTask()
|
|
|
+ if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
|
|
|
+ const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
|
|
|
+ (m) => m.role === "assistant",
|
|
|
+ )
|
|
|
+ resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
|
|
|
}
|
|
|
- if (summaryAnimationState.get(id) === "animating") {
|
|
|
- summaryAnimationState.set(id, "done")
|
|
|
+ return resolved
|
|
|
+ })
|
|
|
+ const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
|
|
|
+ const rawStatus = createMemo(() => {
|
|
|
+ const last = lastPart()
|
|
|
+ if (!last) return undefined
|
|
|
+
|
|
|
+ if (last.type === "tool") {
|
|
|
+ switch (last.tool) {
|
|
|
+ case "task":
|
|
|
+ return "Delegating work"
|
|
|
+ case "todowrite":
|
|
|
+ case "todoread":
|
|
|
+ return "Planning next steps"
|
|
|
+ case "read":
|
|
|
+ return "Gathering context"
|
|
|
+ case "list":
|
|
|
+ case "grep":
|
|
|
+ case "glob":
|
|
|
+ return "Searching the codebase"
|
|
|
+ case "webfetch":
|
|
|
+ return "Searching the web"
|
|
|
+ case "edit":
|
|
|
+ case "write":
|
|
|
+ return "Making edits"
|
|
|
+ case "bash":
|
|
|
+ return "Running commands"
|
|
|
+ default:
|
|
|
+ break
|
|
|
+ }
|
|
|
+ } else if (last.type === "reasoning") {
|
|
|
+ return "Thinking"
|
|
|
+ } else if (last.type === "text") {
|
|
|
+ return "Gathering thoughts"
|
|
|
}
|
|
|
+ return undefined
|
|
|
})
|
|
|
- })
|
|
|
|
|
|
- const assistantMessages = createMemo(() => {
|
|
|
- return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
|
|
|
- })
|
|
|
- const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
|
|
|
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
|
|
|
- const parts = createMemo(() => data.store.part[msg().id])
|
|
|
- const lastTextPart = createMemo(() =>
|
|
|
- assistantMessageParts()
|
|
|
- .filter((p) => p?.type === "text")
|
|
|
- ?.at(-1),
|
|
|
- )
|
|
|
- const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool"))
|
|
|
- const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
|
|
|
- const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
|
|
|
- const [completed, setCompleted] = createSignal(initialCompleted)
|
|
|
- const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
|
|
|
- const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
|
|
|
+ function duration() {
|
|
|
+ const completed = lastAssistantMessage()?.time.completed
|
|
|
+ const from = DateTime.fromMillis(message()!.time.created)
|
|
|
+ const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
|
|
|
+ const interval = Interval.fromDateTimes(from, to)
|
|
|
+ const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
|
|
|
|
|
|
- // Should animate: state is "empty" AND value now exists, or state is "animating"
|
|
|
- // Transition: empty -> animating -> done (done happens on cleanup)
|
|
|
- const animateTitle = createMemo(() => {
|
|
|
- const id = msg().id
|
|
|
- const state = titleAnimationState.get(id)
|
|
|
- const title = msg().summary?.title
|
|
|
- if (state === "animating") {
|
|
|
- return true
|
|
|
- }
|
|
|
- if (state === "empty" && title) {
|
|
|
- titleAnimationState.set(id, "animating")
|
|
|
- return true
|
|
|
- }
|
|
|
- return false
|
|
|
- })
|
|
|
- const animateSummary = createMemo(() => {
|
|
|
- const id = msg().id
|
|
|
- const state = summaryAnimationState.get(id)
|
|
|
- const value = summary()
|
|
|
- if (state === "animating") {
|
|
|
- return true
|
|
|
- }
|
|
|
- if (state === "empty" && value) {
|
|
|
- summaryAnimationState.set(id, "animating")
|
|
|
- return true
|
|
|
+ return interval.toDuration(unit).normalize().toHuman({
|
|
|
+ notation: "compact",
|
|
|
+ unitDisplay: "narrow",
|
|
|
+ compactDisplay: "short",
|
|
|
+ showZeros: false,
|
|
|
+ })
|
|
|
}
|
|
|
- return false
|
|
|
- })
|
|
|
|
|
|
- createEffect(() => {
|
|
|
- const done = !messageWorking()
|
|
|
- setTimeout(() => setCompleted(done), 1200)
|
|
|
- })
|
|
|
+ const [store, setStore] = createStore({
|
|
|
+ status: rawStatus(),
|
|
|
+ stepsExpanded: true,
|
|
|
+ duration: duration(),
|
|
|
+ })
|
|
|
|
|
|
- return (
|
|
|
- <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
|
|
|
- {/* Title */}
|
|
|
- <div data-slot="session-turn-message-header">
|
|
|
- <div data-slot="session-turn-message-title">
|
|
|
- <Show
|
|
|
- when={!animateTitle()}
|
|
|
- fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
|
|
|
- >
|
|
|
- <h1>{msg().summary?.title}</h1>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- <div data-slot="session-turn-message-content">
|
|
|
- <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
|
|
|
- </div>
|
|
|
- {/* Summary */}
|
|
|
- <Show when={completed()}>
|
|
|
- <div data-slot="session-turn-summary-section">
|
|
|
- <div data-slot="session-turn-summary-header">
|
|
|
- <h2 data-slot="session-turn-summary-title">
|
|
|
+ createEffect(() => {
|
|
|
+ const timer = setInterval(() => {
|
|
|
+ setStore("duration", duration())
|
|
|
+ }, 1000)
|
|
|
+ onCleanup(() => clearInterval(timer))
|
|
|
+ })
|
|
|
+
|
|
|
+ let lastStatusChange = Date.now()
|
|
|
+ let statusTimeout: number | undefined
|
|
|
+ createEffect(() => {
|
|
|
+ const newStatus = rawStatus()
|
|
|
+ if (newStatus === store.status || !newStatus) return
|
|
|
+
|
|
|
+ const timeSinceLastChange = Date.now() - lastStatusChange
|
|
|
+
|
|
|
+ if (timeSinceLastChange >= 2500) {
|
|
|
+ setStore("status", newStatus)
|
|
|
+ lastStatusChange = Date.now()
|
|
|
+ if (statusTimeout) {
|
|
|
+ clearTimeout(statusTimeout)
|
|
|
+ statusTimeout = undefined
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (statusTimeout) clearTimeout(statusTimeout)
|
|
|
+ statusTimeout = setTimeout(() => {
|
|
|
+ setStore("status", rawStatus())
|
|
|
+ lastStatusChange = Date.now()
|
|
|
+ statusTimeout = undefined
|
|
|
+ }, 2500 - timeSinceLastChange) as unknown as number
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect((prev) => {
|
|
|
+ const isWorking = working()
|
|
|
+ if (prev && !isWorking && !userScrolled()) {
|
|
|
+ setStore("stepsExpanded", false)
|
|
|
+ }
|
|
|
+ return isWorking
|
|
|
+ }, working())
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ data-message={message().id}
|
|
|
+ data-slot="session-turn-message-container"
|
|
|
+ class={props.classes?.container}
|
|
|
+ style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }}
|
|
|
+ >
|
|
|
+ {/* Sticky Header */}
|
|
|
+ <div ref={setStickyHeaderRef} data-slot="session-turn-sticky-header">
|
|
|
+ <div data-slot="session-turn-message-header">
|
|
|
+ <div data-slot="session-turn-message-title">
|
|
|
<Switch>
|
|
|
- <Match when={msg().summary?.diffs?.length}>Summary</Match>
|
|
|
- <Match when={true}>Response</Match>
|
|
|
+ <Match when={working()}>
|
|
|
+ <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <h1>{message().summary?.title}</h1>
|
|
|
+ </Match>
|
|
|
</Switch>
|
|
|
- </h2>
|
|
|
- <Show when={summary()}>
|
|
|
- {(summary) => (
|
|
|
- <Markdown
|
|
|
- data-slot="session-turn-markdown"
|
|
|
- data-diffs={!!msg().summary?.diffs?.length}
|
|
|
- data-fade={!msg().summary?.diffs?.length && animateSummary()}
|
|
|
- text={summary()}
|
|
|
- />
|
|
|
- )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div data-slot="session-turn-message-content">
|
|
|
+ <Message message={message()} parts={parts()} />
|
|
|
+ </div>
|
|
|
+ <div data-slot="session-turn-response-trigger">
|
|
|
+ <Button
|
|
|
+ data-slot="session-turn-collapsible-trigger-content"
|
|
|
+ variant="ghost"
|
|
|
+ size="small"
|
|
|
+ onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
|
|
|
+ >
|
|
|
+ <Show when={working()}>
|
|
|
+ <Spinner />
|
|
|
+ </Show>
|
|
|
+ <Switch>
|
|
|
+ <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
|
|
|
+ <Match when={store.stepsExpanded}>Hide steps</Match>
|
|
|
+ <Match when={!store.stepsExpanded}>Show steps</Match>
|
|
|
+ </Switch>
|
|
|
+ <span>·</span>
|
|
|
+ <span>{store.duration}</span>
|
|
|
+ <Icon name="chevron-grabber-vertical" size="small" />
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {/* Response */}
|
|
|
+ <Show when={store.stepsExpanded}>
|
|
|
+ <div data-slot="session-turn-collapsible-content-inner">
|
|
|
+ <For each={assistantMessages()}>
|
|
|
+ {(assistantMessage) => {
|
|
|
+ const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
|
|
|
+ const last = createMemo(() =>
|
|
|
+ parts()
|
|
|
+ .filter((p) => p?.type === "text")
|
|
|
+ .at(-1),
|
|
|
+ )
|
|
|
+ return (
|
|
|
+ <Switch>
|
|
|
+ <Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
|
|
|
+ <Message
|
|
|
+ message={assistantMessage}
|
|
|
+ parts={parts().filter((p) => p?.id !== last()?.id)}
|
|
|
+ />
|
|
|
+ </Match>
|
|
|
+ <Match when={true}>
|
|
|
+ <Message message={assistantMessage} parts={parts()} />
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ </For>
|
|
|
+ <Show when={error()}>
|
|
|
+ <Card variant="error" class="error-card">
|
|
|
+ {error()?.data?.message as string}
|
|
|
+ </Card>
|
|
|
</Show>
|
|
|
</div>
|
|
|
- <Accordion data-slot="session-turn-accordion" multiple>
|
|
|
- <For each={msg().summary?.diffs ?? []}>
|
|
|
- {(diff) => (
|
|
|
- <Accordion.Item value={diff.file}>
|
|
|
- <StickyAccordionHeader>
|
|
|
- <Accordion.Trigger>
|
|
|
- <div data-slot="session-turn-accordion-trigger-content">
|
|
|
- <div data-slot="session-turn-file-info">
|
|
|
- <FileIcon
|
|
|
- node={{ path: diff.file, type: "file" }}
|
|
|
- data-slot="session-turn-file-icon"
|
|
|
- />
|
|
|
- <div data-slot="session-turn-file-path">
|
|
|
- <Show when={diff.file.includes("/")}>
|
|
|
- <span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span>
|
|
|
- </Show>
|
|
|
- <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
|
|
+ </Show>
|
|
|
+ {/* Summary */}
|
|
|
+ <Show when={!working()}>
|
|
|
+ <div data-slot="session-turn-summary-section">
|
|
|
+ <div data-slot="session-turn-summary-header">
|
|
|
+ <h2 data-slot="session-turn-summary-title">
|
|
|
+ <Switch>
|
|
|
+ <Match when={message().summary?.diffs?.length}>Summary</Match>
|
|
|
+ <Match when={true}>Response</Match>
|
|
|
+ </Switch>
|
|
|
+ </h2>
|
|
|
+ <Show when={summary()}>
|
|
|
+ {(summary) => (
|
|
|
+ <Markdown
|
|
|
+ data-slot="session-turn-markdown"
|
|
|
+ data-diffs={!!message().summary?.diffs?.length}
|
|
|
+ text={summary()}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
+ <Accordion data-slot="session-turn-accordion" multiple>
|
|
|
+ <For each={message().summary?.diffs ?? []}>
|
|
|
+ {(diff) => (
|
|
|
+ <Accordion.Item value={diff.file}>
|
|
|
+ <StickyAccordionHeader>
|
|
|
+ <Accordion.Trigger>
|
|
|
+ <div data-slot="session-turn-accordion-trigger-content">
|
|
|
+ <div data-slot="session-turn-file-info">
|
|
|
+ <FileIcon
|
|
|
+ node={{ path: diff.file, type: "file" }}
|
|
|
+ data-slot="session-turn-file-icon"
|
|
|
+ />
|
|
|
+ <div data-slot="session-turn-file-path">
|
|
|
+ <Show when={diff.file.includes("/")}>
|
|
|
+ <span data-slot="session-turn-directory">{getDirectory(diff.file)}‎</span>
|
|
|
+ </Show>
|
|
|
+ <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div data-slot="session-turn-accordion-actions">
|
|
|
+ <DiffChanges changes={diff} />
|
|
|
+ <Icon name="chevron-grabber-vertical" size="small" />
|
|
|
</div>
|
|
|
</div>
|
|
|
- <div data-slot="session-turn-accordion-actions">
|
|
|
- <DiffChanges changes={diff} />
|
|
|
- <Icon name="chevron-grabber-vertical" size="small" />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- </Accordion.Trigger>
|
|
|
- </StickyAccordionHeader>
|
|
|
- <Accordion.Content data-slot="session-turn-accordion-content">
|
|
|
- <Dynamic
|
|
|
- component={diffComponent}
|
|
|
- before={{
|
|
|
- name: diff.file!,
|
|
|
- contents: diff.before!,
|
|
|
- cacheKey: checksum(diff.before!),
|
|
|
- }}
|
|
|
- after={{
|
|
|
- name: diff.file!,
|
|
|
- contents: diff.after!,
|
|
|
- cacheKey: checksum(diff.after!),
|
|
|
- }}
|
|
|
- />
|
|
|
- </Accordion.Content>
|
|
|
- </Accordion.Item>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </Accordion>
|
|
|
- </div>
|
|
|
- </Show>
|
|
|
- <Show when={error() && !detailsExpanded()}>
|
|
|
- <Card variant="error" class="error-card">
|
|
|
- {error()?.data?.message as string}
|
|
|
- </Card>
|
|
|
- </Show>
|
|
|
- {/* Response */}
|
|
|
- <div data-slot="session-turn-response-section">
|
|
|
- <Switch>
|
|
|
- <Match when={!completed()}>
|
|
|
- <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
|
|
|
- </Match>
|
|
|
- <Match when={completed() && hasToolPart()}>
|
|
|
- <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
|
|
|
- <Collapsible.Trigger>
|
|
|
- <div data-slot="session-turn-collapsible-trigger-content">
|
|
|
- <div data-slot="session-turn-details-text">
|
|
|
- <Switch>
|
|
|
- <Match when={detailsExpanded()}>Hide details</Match>
|
|
|
- <Match when={!detailsExpanded()}>Show details</Match>
|
|
|
- </Switch>
|
|
|
- </div>
|
|
|
- <Collapsible.Arrow />
|
|
|
- </div>
|
|
|
- </Collapsible.Trigger>
|
|
|
- <Collapsible.Content>
|
|
|
- <div data-slot="session-turn-collapsible-content-inner">
|
|
|
- <For each={assistantMessages()}>
|
|
|
- {(assistantMessage) => {
|
|
|
- const parts = createMemo(() => data.store.part[assistantMessage.id])
|
|
|
- const last = createMemo(() =>
|
|
|
- parts()
|
|
|
- .filter((p) => p?.type === "text")
|
|
|
- .at(-1),
|
|
|
- )
|
|
|
- if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
|
|
|
- return (
|
|
|
- <Message
|
|
|
- message={assistantMessage}
|
|
|
- parts={parts().filter((p) => p?.id !== last()?.id)}
|
|
|
- sanitize={sanitizer()}
|
|
|
- />
|
|
|
- )
|
|
|
- }
|
|
|
- return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
|
|
|
- }}
|
|
|
- </For>
|
|
|
- <Show when={error()}>
|
|
|
- <Card variant="error" class="error-card">
|
|
|
- {error()?.data?.message as string}
|
|
|
- </Card>
|
|
|
- </Show>
|
|
|
- </div>
|
|
|
- </Collapsible.Content>
|
|
|
- </Collapsible>
|
|
|
- </Match>
|
|
|
- </Switch>
|
|
|
+ </Accordion.Trigger>
|
|
|
+ </StickyAccordionHeader>
|
|
|
+ <Accordion.Content data-slot="session-turn-accordion-content">
|
|
|
+ <Dynamic
|
|
|
+ component={diffComponent}
|
|
|
+ before={{
|
|
|
+ name: diff.file!,
|
|
|
+ contents: diff.before!,
|
|
|
+ cacheKey: checksum(diff.before!),
|
|
|
+ }}
|
|
|
+ after={{
|
|
|
+ name: diff.file!,
|
|
|
+ contents: diff.after!,
|
|
|
+ cacheKey: checksum(diff.after!),
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </Accordion.Content>
|
|
|
+ </Accordion.Item>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </Accordion>
|
|
|
+ </div>
|
|
|
+ </Show>
|
|
|
+ <Show when={error() && !store.stepsExpanded}>
|
|
|
+ <Card variant="error" class="error-card">
|
|
|
+ {error()?.data?.message as string}
|
|
|
+ </Card>
|
|
|
+ </Show>
|
|
|
</div>
|
|
|
- </div>
|
|
|
- )
|
|
|
- }}
|
|
|
- </Show>
|
|
|
- {props.children}
|
|
|
+ )
|
|
|
+ }}
|
|
|
+ </Show>
|
|
|
+ {props.children}
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
)
|