import { useLocal, useSync } from "@/context" import { Icon, Tooltip } from "@opencode-ai/ui" import { Collapsible } from "@/ui" import type { AssistantMessage, Part, ToolPart } from "@opencode-ai/sdk" import { DateTime } from "luxon" import { createSignal, onMount, For, Match, splitProps, Switch, type ComponentProps, type ParentProps, createEffect, createMemo, Show, } from "solid-js" import { getFilename } from "@/utils" import { Markdown } from "./markdown" import { Code } from "./code" import { createElementSize } from "@solid-primitives/resize-observer" import { createScrollPosition } from "@solid-primitives/scroll" import { ProgressCircle } from "./progress-circle" import { pipe, sumBy } from "remeda" function Part(props: ParentProps & ComponentProps<"div">) { const [local, others] = splitProps(props, ["class", "classList", "children"]) return (

{local.children}

) } function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps) { return ( {props.title}

{props.children}

) } function ReadToolPart(props: { part: ToolPart }) { const sync = useSync() const local = useLocal() return ( Reading file... {(state) => { const path = state().input["filePath"] as string return ( local.file.open(path)}> Read {getFilename(path)} ) }} {(state) => (
Read {getFilename(state().input["filePath"] as string)}
{sync.sanitize(state().error)}
)}
) } function EditToolPart(props: { part: ToolPart }) { const sync = useSync() return ( Preparing edit... {(state) => ( Edit {getFilename(state().input["filePath"] as string)} } > )} {(state) => ( Edit {getFilename(state().input["filePath"] as string)} } >
{sync.sanitize(state().error)}
)}
) } function WriteToolPart(props: { part: ToolPart }) { const sync = useSync() return ( Preparing write... {(state) => ( Write {getFilename(state().input["filePath"] as string)} } >
)}
{(state) => (
Write {getFilename(state().input["filePath"] as string)}
{sync.sanitize(state().error)}
)}
) } function BashToolPart(props: { part: ToolPart }) { const sync = useSync() return ( Writing shell command... {(state) => ( Run command: {state().input["command"]} } > )} {(state) => ( Shell {state().input["command"]} } >
{sync.sanitize(state().error)}
)}
) } function ToolPart(props: { part: ToolPart }) { // read // edit // write // bash // ls // glob // grep // todowrite // todoread // webfetch // websearch // patch // task return (
{props.part.type}:{props.part.tool} } >
) } export default function SessionTimeline(props: { session: string; class?: string }) { const sync = useSync() const [scrollElement, setScrollElement] = createSignal(undefined) const [root, setRoot] = createSignal(undefined) const [tail, setTail] = createSignal(true) const size = createElementSize(root) const scroll = createScrollPosition(scrollElement) const valid = (part: Part) => { if (!part) return false switch (part.type) { case "step-start": case "step-finish": case "file": case "patch": return false case "text": return !part.synthetic case "reasoning": return part.text.trim() case "tool": switch (part.tool) { case "todoread": case "todowrite": case "list": case "grep": return false } return true default: return true } } const session = createMemo(() => sync.session.get(props.session)) const messages = createMemo(() => sync.data.message[props.session] ?? []) const working = createMemo(() => { const last = messages()[messages().length - 1] if (!last) return false if (last.role === "user") return true return !last.time.completed }) const cost = createMemo(() => { const total = pipe( messages(), sumBy((x) => (x.role === "assistant" ? x.cost : 0)), ) return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", }).format(total) }) const last = createMemo(() => { return messages().findLast((x) => x.role === "assistant") as AssistantMessage }) const model = createMemo(() => { if (!last()) return const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] return model }) const tokens = createMemo(() => { if (!last()) return const tokens = last().tokens const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write return new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", }).format(total) }) const context = createMemo(() => { if (!last()) return if (!model()?.limit.context) return 0 const tokens = last().tokens const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write return Math.round((total / model()!.limit.context) * 100) }) const getScrollParent = (el: HTMLElement | null): HTMLElement | undefined => { let p = el?.parentElement while (p && p !== document.body) { const s = getComputedStyle(p) if (s.overflowY === "auto" || s.overflowY === "scroll") return p p = p.parentElement } return undefined } createEffect(() => { if (!root()) return setScrollElement(getScrollParent(root()!)) }) const scrollToBottom = () => { const element = scrollElement() if (!element) return element.scrollTop = element.scrollHeight } createEffect(() => { size.height if (tail()) scrollToBottom() }) createEffect(() => { if (working()) { setTail(true) scrollToBottom() } }) let lastScrollY = 0 createEffect(() => { if (scroll.y < lastScrollY) { setTail(false) } lastScrollY = scroll.y }) const duration = (part: Part) => { switch (part.type) { default: if ( "time" in part && part.time && "start" in part.time && part.time.start && "end" in part.time && part.time.end ) { const start = DateTime.fromMillis(part.time.start) const end = DateTime.fromMillis(part.time.end) return end.diff(start).toFormat("s") } return "" } } return (
{context()}%
{cost()}
    {(message) => (
    {(part) => (
  • {part.type}
  • }> {(part) => (
    {part().text}
    )}
    {(part) => ( Thinking}> Thought for {duration(part())}s } > )} {(part) => } )}
)}
Raw Session Data
  • session
  • {(message) => ( <>
  • {message.role === "user" ? "user" : "assistant"}
  • {(part) => (
  • {part.type}
  • )}
    )}
) }