summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/components/session-timeline.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/desktop/src/components/session-timeline.tsx')
-rw-r--r--packages/desktop/src/components/session-timeline.tsx459
1 files changed, 459 insertions, 0 deletions
diff --git a/packages/desktop/src/components/session-timeline.tsx b/packages/desktop/src/components/session-timeline.tsx
new file mode 100644
index 000000000..07d93031e
--- /dev/null
+++ b/packages/desktop/src/components/session-timeline.tsx
@@ -0,0 +1,459 @@
+import { useLocal, useSync } from "@/context"
+import { Collapsible, Icon } from "@/ui"
+import type { 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"
+
+function Part(props: ParentProps & ComponentProps<"div">) {
+ const [local, others] = splitProps(props, ["class", "classList", "children"])
+ return (
+ <div
+ classList={{
+ ...(local.classList ?? {}),
+ "h-6 flex items-center": true,
+ [local.class ?? ""]: !!local.class,
+ }}
+ {...others}
+ >
+ <p class="text-xs leading-4 text-left text-text-muted/60 font-medium">{local.children}</p>
+ </div>
+ )
+}
+
+function CollapsiblePart(props: { title: ParentProps["children"] } & ParentProps & ComponentProps<typeof Collapsible>) {
+ return (
+ <Collapsible {...props}>
+ <Collapsible.Trigger class="peer/collapsible">
+ <Part>{props.title}</Part>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <p class="flex-auto py-1 text-xs min-w-0 text-pretty">
+ <span class="text-text-muted/60 break-words">{props.children}</span>
+ </p>
+ </Collapsible.Content>
+ </Collapsible>
+ )
+}
+
+function ReadToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
+ const local = useLocal()
+ return (
+ <Switch>
+ <Match when={props.part.state.status === "pending"}>
+ <Part>Reading file...</Part>
+ </Match>
+ <Match when={props.part.state.status === "completed" && props.part.state}>
+ {(state) => {
+ const path = state().input["filePath"] as string
+ return (
+ <Part class="cursor-pointer" onClick={() => local.file.open(path)}>
+ <span class="text-text-muted">Read</span> {getFilename(path)}
+ </Part>
+ )
+ }}
+ </Match>
+ <Match when={props.part.state.status === "error" && props.part.state}>
+ {(state) => (
+ <div>
+ <Part>
+ <span class="text-text-muted">Read</span> {getFilename(state().input["filePath"] as string)}
+ </Part>
+ <div class="text-error">{sync.sanitize(state().error)}</div>
+ </div>
+ )}
+ </Match>
+ </Switch>
+ )
+}
+
+function EditToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
+ return (
+ <Switch>
+ <Match when={props.part.state.status === "pending"}>
+ <Part>Preparing edit...</Part>
+ </Match>
+ <Match when={props.part.state.status === "completed" && props.part.state}>
+ {(state) => (
+ <CollapsiblePart
+ defaultOpen
+ title={
+ <>
+ <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
+ </>
+ }
+ >
+ <Code path={state().input["filePath"] as string} code={state().metadata["diff"] as string} />
+ </CollapsiblePart>
+ )}
+ </Match>
+ <Match when={props.part.state.status === "error" && props.part.state}>
+ {(state) => (
+ <CollapsiblePart
+ title={
+ <>
+ <span class="text-text-muted">Edit</span> {getFilename(state().input["filePath"] as string)}
+ </>
+ }
+ >
+ <div class="text-error">{sync.sanitize(state().error)}</div>
+ </CollapsiblePart>
+ )}
+ </Match>
+ </Switch>
+ )
+}
+
+function WriteToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
+ return (
+ <Switch>
+ <Match when={props.part.state.status === "pending"}>
+ <Part>Preparing write...</Part>
+ </Match>
+ <Match when={props.part.state.status === "completed" && props.part.state}>
+ {(state) => (
+ <CollapsiblePart
+ title={
+ <>
+ <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
+ </>
+ }
+ >
+ <div class="p-2 bg-background-panel rounded-md border border-border-subtle"></div>
+ </CollapsiblePart>
+ )}
+ </Match>
+ <Match when={props.part.state.status === "error" && props.part.state}>
+ {(state) => (
+ <div>
+ <Part>
+ <span class="text-text-muted">Write</span> {getFilename(state().input["filePath"] as string)}
+ </Part>
+ <div class="text-error">{sync.sanitize(state().error)}</div>
+ </div>
+ )}
+ </Match>
+ </Switch>
+ )
+}
+
+function BashToolPart(props: { part: ToolPart }) {
+ const sync = useSync()
+ return (
+ <Switch>
+ <Match when={props.part.state.status === "pending"}>
+ <Part>Writing shell command...</Part>
+ </Match>
+ <Match when={props.part.state.status === "completed" && props.part.state}>
+ {(state) => (
+ <CollapsiblePart
+ defaultOpen
+ title={
+ <>
+ <span class="text-text-muted">Run command:</span> {state().input["command"]}
+ </>
+ }
+ >
+ <Markdown text={`\`\`\`command\n${state().input["command"]}\n${state().output}\`\`\``} />
+ </CollapsiblePart>
+ )}
+ </Match>
+ <Match when={props.part.state.status === "error" && props.part.state}>
+ {(state) => (
+ <CollapsiblePart
+ title={
+ <>
+ <span class="text-text-muted">Shell</span> {state().input["command"]}
+ </>
+ }
+ >
+ <div class="text-error">{sync.sanitize(state().error)}</div>
+ </CollapsiblePart>
+ )}
+ </Match>
+ </Switch>
+ )
+}
+
+function ToolPart(props: { part: ToolPart }) {
+ // read
+ // edit
+ // write
+ // bash
+ // ls
+ // glob
+ // grep
+ // todowrite
+ // todoread
+ // webfetch
+ // websearch
+ // patch
+ // task
+ return (
+ <div class="min-w-0 flex-auto text-xs">
+ <Switch
+ fallback={
+ <span>
+ {props.part.type}:{props.part.tool}
+ </span>
+ }
+ >
+ <Match when={props.part.tool === "read"}>
+ <ReadToolPart part={props.part} />
+ </Match>
+ <Match when={props.part.tool === "edit"}>
+ <EditToolPart part={props.part} />
+ </Match>
+ <Match when={props.part.tool === "write"}>
+ <WriteToolPart part={props.part} />
+ </Match>
+ <Match when={props.part.tool === "bash"}>
+ <BashToolPart part={props.part} />
+ </Match>
+ </Switch>
+ </div>
+ )
+}
+
+export default function SessionTimeline(props: { session: string; class?: string }) {
+ const sync = useSync()
+ const [scrollElement, setScrollElement] = createSignal<HTMLElement | undefined>(undefined)
+ const [root, setRoot] = createSignal<HTMLDivElement | undefined>(undefined)
+ const [tail, setTail] = createSignal(true)
+ const size = createElementSize(root)
+ const scroll = createScrollPosition(scrollElement)
+
+ onMount(() => sync.session.sync(props.session))
+ 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 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 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()
+ default:
+ return true
+ }
+ }
+
+ 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 (
+ <div
+ ref={setRoot}
+ classList={{
+ "p-4 select-text flex flex-col gap-y-1": true,
+ [props.class ?? ""]: !!props.class,
+ }}
+ >
+ <ul role="list" class="flex flex-col gap-1">
+ <For each={messages()}>
+ {(message) => (
+ <For each={sync.data.part[message.id]?.filter(valid)}>
+ {(part) => (
+ <li class="group/li">
+ <Switch fallback={<div class="flex-auto min-w-0 text-xs mt-1 text-left">{part.type}</div>}>
+ <Match when={part.type === "text" && part}>
+ {(part) => (
+ <Switch>
+ <Match when={message.role === "user"}>
+ <div class="w-full flex flex-col items-end justify-stretch gap-y-1.5 min-w-0 mt-5 group-first/li:mt-0">
+ <p class="w-full rounded-md p-3 ring-1 ring-text/15 ring-inset text-xs bg-background-panel">
+ <span class="font-medium text-text whitespace-pre-wrap break-words">{part().text}</span>
+ </p>
+ <p class="text-xs text-text-muted">
+ {DateTime.fromMillis(message.time.created).toRelative()} ยท{" "}
+ {sync.data.config.username ?? "user"}
+ </p>
+ </div>
+ </Match>
+ <Match when={message.role === "assistant"}>
+ <Markdown text={sync.sanitize(part().text)} class="text-text mt-1" />
+ </Match>
+ </Switch>
+ )}
+ </Match>
+ <Match when={part.type === "reasoning" && part}>
+ {(part) => (
+ <CollapsiblePart
+ title={
+ <Switch fallback={<span class="text-text-muted">Thinking</span>}>
+ <Match when={part().time.end}>
+ <span class="text-text-muted">Thought</span> for {duration(part())}s
+ </Match>
+ </Switch>
+ }
+ >
+ <Markdown text={part().text} />
+ </CollapsiblePart>
+ )}
+ </Match>
+ <Match when={part.type === "tool" && part}>{(part) => <ToolPart part={part()} />}</Match>
+ </Switch>
+ </li>
+ )}
+ </For>
+ )}
+ </For>
+ </ul>
+ <Show when={false}>
+ <Collapsible defaultOpen={false}>
+ <Collapsible.Trigger>
+ <div class="mt-12 ml-1 flex items-center gap-x-2 text-xs text-text-muted">
+ <Icon name="file-code" size={16} />
+ <span>Raw Session Data</span>
+ <Collapsible.Arrow size={18} class="text-text-muted" />
+ </div>
+ </Collapsible.Trigger>
+ <Collapsible.Content class="mt-5">
+ <ul role="list" class="space-y-2">
+ <li>
+ <Collapsible>
+ <Collapsible.Trigger>
+ <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
+ <Icon name="file-code" size={16} />
+ <span>session</span>
+ <Collapsible.Arrow size={18} class="text-text-muted" />
+ </div>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <Code path="session.json" code={JSON.stringify(session(), null, 2)} />
+ </Collapsible.Content>
+ </Collapsible>
+ </li>
+ <For each={messages()}>
+ {(message) => (
+ <>
+ <li>
+ <Collapsible>
+ <Collapsible.Trigger>
+ <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
+ <Icon name="file-code" size={16} />
+ <span>{message.role === "user" ? "user" : "assistant"}</span>
+ <Collapsible.Arrow size={18} class="text-text-muted" />
+ </div>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <Code path={message.id + ".json"} code={JSON.stringify(message, null, 2)} />
+ </Collapsible.Content>
+ </Collapsible>
+ </li>
+ <For each={sync.data.part[message.id]}>
+ {(part) => (
+ <li>
+ <Collapsible>
+ <Collapsible.Trigger>
+ <div class="flex items-center gap-x-2 text-xs text-text-muted ml-1">
+ <Icon name="file-code" size={16} />
+ <span>{part.type}</span>
+ <Collapsible.Arrow size={18} class="text-text-muted" />
+ </div>
+ </Collapsible.Trigger>
+ <Collapsible.Content>
+ <Code path={message.id + "." + part.id + ".json"} code={JSON.stringify(part, null, 2)} />
+ </Collapsible.Content>
+ </Collapsible>
+ </li>
+ )}
+ </For>
+ </>
+ )}
+ </For>
+ </ul>
+ </Collapsible.Content>
+ </Collapsible>
+ </Show>
+ </div>
+ )
+}