Răsfoiți Sursa

wip(desktop): session turn state consolidation

Adam 2 luni în urmă
părinte
comite
d81d63045a
1 a modificat fișierele cu 303 adăugiri și 317 ștergeri
  1. 303 317
      packages/ui/src/components/session-turn.tsx

+ 303 - 317
packages/ui/src/components/session-turn.tsx

@@ -40,6 +40,9 @@ export function SessionTurn(
       .sort((a, b) => a.id.localeCompare(b.id)),
       .sort((a, b) => a.id.localeCompare(b.id)),
   )
   )
   const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
   const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
+
+  if (!message()) return null
+
   const status = createMemo(
   const status = createMemo(
     () =>
     () =>
       data.store.session_status[props.sessionID] ?? {
       data.store.session_status[props.sessionID] ?? {
@@ -49,379 +52,362 @@ export function SessionTurn(
   const working = createMemo(() => status()?.type !== "idle")
   const working = createMemo(() => status()?.type !== "idle")
 
 
   let scrollRef: HTMLDivElement | undefined
   let scrollRef: HTMLDivElement | undefined
-  const [state, setState] = createStore({
+
+  const assistantMessages = createMemo(() => {
+    return messages()?.filter((m) => m.role === "assistant" && m.parentID == message()!.id) as AssistantMessage[]
+  })
+  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)
+
+  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 msgs = data.store.message[task.state.metadata.sessionId as string]?.filter((m) => m.role === "assistant")
+      resolved = msgs?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
+    }
+    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") {
+      const text = last.text ?? ""
+      const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
+      if (match) return `Thinking · ${match[1].trim()}`
+      return "Thinking"
+    } else if (last.type === "text") {
+      return "Gathering thoughts"
+    }
+    return undefined
+  })
+
+  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"]
+
+    return interval.toDuration(unit).normalize().toHuman({
+      notation: "compact",
+      unitDisplay: "narrow",
+      compactDisplay: "short",
+      showZeros: false,
+    })
+  }
+
+  const [store, setStore] = createStore({
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
     userScrolled: false,
     userScrolled: false,
     stickyHeaderHeight: 0,
     stickyHeaderHeight: 0,
     scrollY: 0,
     scrollY: 0,
     autoScrolling: false,
     autoScrolling: false,
+    status: rawStatus(),
+    stepsExpanded: true,
+    duration: duration(),
+    lastStatusChange: Date.now(),
+    statusTimeout: undefined as number | undefined,
   })
   })
 
 
   function handleScroll() {
   function handleScroll() {
     if (!scrollRef) return
     if (!scrollRef) return
     // prevents scroll loops
     // prevents scroll loops
     if (working() && scrollRef.scrollTop < 100) return
     if (working() && scrollRef.scrollTop < 100) return
-    setState("scrollY", scrollRef.scrollTop)
-    if (state.autoScrolling) return
+    setStore("scrollY", scrollRef.scrollTop)
+    if (store.autoScrolling) return
     const { scrollTop, scrollHeight, clientHeight } = scrollRef
     const { scrollTop, scrollHeight, clientHeight } = scrollRef
     const atBottom = scrollHeight - scrollTop - clientHeight < 50
     const atBottom = scrollHeight - scrollTop - clientHeight < 50
     if (!atBottom && working()) {
     if (!atBottom && working()) {
-      setState("userScrolled", true)
+      setStore("userScrolled", true)
     }
     }
   }
   }
 
 
   function handleInteraction() {
   function handleInteraction() {
     if (working()) {
     if (working()) {
-      setState("userScrolled", true)
+      setStore("userScrolled", true)
     }
     }
   }
   }
 
 
   function scrollToBottom() {
   function scrollToBottom() {
-    if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
-    setState("autoScrolling", true)
+    if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return
+    setStore("autoScrolling", true)
     requestAnimationFrame(() => {
     requestAnimationFrame(() => {
       scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
       scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
       requestAnimationFrame(() => {
       requestAnimationFrame(() => {
-        setState("autoScrolling", false)
+        setStore("autoScrolling", false)
       })
       })
     })
     })
   }
   }
 
 
   createEffect(() => {
   createEffect(() => {
     if (!working()) {
     if (!working()) {
-      setState("userScrolled", false)
+      setStore("userScrolled", false)
     }
     }
   })
   })
 
 
   createResizeObserver(
   createResizeObserver(
-    () => state.stickyTitleRef,
+    () => store.stickyTitleRef,
     ({ height }) => {
     ({ height }) => {
-      const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
-      setState("stickyHeaderHeight", height + triggerHeight + 8)
+      const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
+      setStore("stickyHeaderHeight", height + triggerHeight + 8)
     },
     },
   )
   )
 
 
   createResizeObserver(
   createResizeObserver(
-    () => state.stickyTriggerRef,
+    () => store.stickyTriggerRef,
     ({ height }) => {
     ({ height }) => {
-      const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0
-      setState("stickyHeaderHeight", titleHeight + height + 8)
+      const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
+      setStore("stickyHeaderHeight", titleHeight + height + 8)
     },
     },
   )
   )
 
 
-  return (
-    <div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${state.scrollY}px` }}>
-      <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
-        <div onClick={handleInteraction}>
-          <Show when={message()}>
-            {(message) => {
-              const assistantMessages = createMemo(() => {
-                return messages()?.filter(
-                  (m) => m.role === "assistant" && m.parentID == message().id,
-                ) as AssistantMessage[]
-              })
-              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,
-              )
-
-              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()
-                }
-                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") {
-                  const text = last.text ?? ""
-                  const match = text.trimStart().match(/^\*\*(.+?)\*\*/)
-                  if (match) return `Thinking · ${match[1].trim()}`
-                  return "Thinking"
-                } else if (last.type === "text") {
-                  return "Gathering thoughts"
-                }
-                return undefined
-              })
-
-              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"]
-
-                return interval.toDuration(unit).normalize().toHuman({
-                  notation: "compact",
-                  unitDisplay: "narrow",
-                  compactDisplay: "short",
-                  showZeros: false,
-                })
-              }
-
-              createEffect(() => {
-                lastPart()
-                scrollToBottom()
-              })
-
-              const [store, setStore] = createStore({
-                status: rawStatus(),
-                stepsExpanded: true,
-                duration: duration(),
-              })
+  createEffect(() => {
+    lastPart()
+    scrollToBottom()
+  })
 
 
-              createEffect(() => {
-                const timer = setInterval(() => {
-                  setStore("duration", duration())
-                }, 1000)
-                onCleanup(() => clearInterval(timer))
-              })
+  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
+  createEffect(() => {
+    const newStatus = rawStatus()
+    if (newStatus === store.status || !newStatus) return
 
 
-                const timeSinceLastChange = Date.now() - lastStatusChange
+    const timeSinceLastChange = Date.now() - store.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
-                }
-              })
+    if (timeSinceLastChange >= 2500) {
+      setStore("status", newStatus)
+      setStore("lastStatusChange", Date.now())
+      if (store.statusTimeout) {
+        clearTimeout(store.statusTimeout)
+        setStore("statusTimeout", undefined)
+      }
+    } else {
+      if (store.statusTimeout) clearTimeout(store.statusTimeout)
+      setStore(
+        "statusTimeout",
+        setTimeout(() => {
+          setStore("status", rawStatus())
+          setStore("lastStatusChange", Date.now())
+          setStore("statusTimeout", undefined)
+        }, 2500 - timeSinceLastChange) as unknown as number,
+      )
+    }
+  })
 
 
-              createEffect((prev) => {
-                const isWorking = working()
-                if (prev && !isWorking && !state.userScrolled) {
-                  setStore("stepsExpanded", false)
-                }
-                return isWorking
-              }, working())
+  createEffect((prev) => {
+    const isWorking = working()
+    if (prev && !isWorking && !store.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": `${state.stickyHeaderHeight}px` }}
-                >
-                  {/* Title (sticky) */}
-                  <div ref={(el) => setState("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
-                    <div data-slot="session-turn-message-header">
-                      <div data-slot="session-turn-message-title">
-                        <Switch>
-                          <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>
-                      </div>
-                    </div>
-                  </div>
-                  {/* User Message */}
-                  <div data-slot="session-turn-message-content">
-                    <Message message={message()} parts={parts()} />
-                  </div>
-                  {/* Trigger (sticky) */}
-                  <div ref={(el) => setState("stickyTriggerRef", el)} 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>
+  return (
+    <div data-component="session-turn" class={props.classes?.root} style={{ "--scroll-y": `${store.scrollY}px` }}>
+      <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
+        <div onClick={handleInteraction}>
+          <div
+            data-message={message()!.id}
+            data-slot="session-turn-message-container"
+            class={props.classes?.container}
+            style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
+          >
+            {/* Title (sticky) */}
+            <div ref={(el) => setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
+              <div data-slot="session-turn-message-header">
+                <div data-slot="session-turn-message-title">
+                  <Switch>
+                    <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>
+                </div>
+              </div>
+            </div>
+            {/* User Message */}
+            <div data-slot="session-turn-message-content">
+              <Message message={message()!} parts={parts()} />
+            </div>
+            {/* Trigger (sticky) */}
+            <div ref={(el) => setStore("stickyTriggerRef", el)} 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>
+            {/* 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>
                       <Switch>
-                        <Match when={working()}>{store.status ?? "Considering next steps"}</Match>
-                        <Match when={store.stepsExpanded}>Hide steps</Match>
-                        <Match when={!store.stepsExpanded}>Show steps</Match>
+                        <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>
                       </Switch>
-                      <span>·</span>
-                      <span>{store.duration}</span>
-                      <Icon name="chevron-grabber-vertical" size="small" />
-                    </Button>
-                  </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>
-                  </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)}&lrm;</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">
-                                <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>
+                    )
+                  }}
+                </For>
+                <Show when={error()}>
+                  <Card variant="error" class="error-card">
+                    {error()?.data?.message as string}
+                  </Card>
+                </Show>
+              </div>
+            </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>
                   </Show>
                 </div>
                 </div>
-              )
-            }}
-          </Show>
+                <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)}&lrm;</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">
+                          <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>
           {props.children}
           {props.children}
         </div>
         </div>
       </div>
       </div>