Browse Source

fix(desktop): rendering shell mode messages

Adam 2 months ago
parent
commit
c868a4088d

+ 13 - 0
packages/ui/src/components/message-part.css

@@ -152,9 +152,22 @@
   align-items: flex-start;
   align-items: flex-start;
   justify-content: flex-start;
   justify-content: flex-start;
 
 
+  [data-component="markdown"] {
+    width: 100%;
+    min-width: 0;
+
+    pre {
+      margin: 0;
+      padding: 0;
+      background-color: transparent !important;
+      border: none !important;
+    }
+  }
+
   pre {
   pre {
     margin: 0;
     margin: 0;
     padding: 0;
     padding: 0;
+    background: none;
   }
   }
 
 
   &[data-scrollable] {
   &[data-scrollable] {

+ 24 - 2
packages/ui/src/components/message-part.tsx

@@ -69,6 +69,7 @@ export interface MessagePartProps {
   part: PartType
   part: PartType
   message: MessageType
   message: MessageType
   hideDetails?: boolean
   hideDetails?: boolean
+  defaultOpen?: boolean
 }
 }
 
 
 export type PartComponent = Component<MessagePartProps>
 export type PartComponent = Component<MessagePartProps>
@@ -208,7 +209,13 @@ export function Part(props: MessagePartProps) {
   const component = createMemo(() => PART_MAPPING[props.part.type])
   const component = createMemo(() => PART_MAPPING[props.part.type])
   return (
   return (
     <Show when={component()}>
     <Show when={component()}>
-      <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
+      <Dynamic
+        component={component()}
+        part={props.part}
+        message={props.message}
+        hideDetails={props.hideDetails}
+        defaultOpen={props.defaultOpen}
+      />
     </Show>
     </Show>
   )
   )
 }
 }
@@ -219,6 +226,7 @@ export interface ToolProps {
   tool: string
   tool: string
   output?: string
   output?: string
   hideDetails?: boolean
   hideDetails?: boolean
+  defaultOpen?: boolean
 }
 }
 
 
 export type ToolComponent = Component<ToolProps>
 export type ToolComponent = Component<ToolProps>
@@ -286,6 +294,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
             metadata={metadata}
             metadata={metadata}
             output={part.state.status === "completed" ? part.state.output : undefined}
             output={part.state.status === "completed" ? part.state.output : undefined}
             hideDetails={props.hideDetails}
             hideDetails={props.hideDetails}
+            defaultOpen={props.defaultOpen}
           />
           />
         </Match>
         </Match>
       </Switch>
       </Switch>
@@ -326,6 +335,7 @@ ToolRegistry.register({
   render(props) {
   render(props) {
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         icon="glasses"
         icon="glasses"
         trigger={{
         trigger={{
           title: "Read",
           title: "Read",
@@ -340,7 +350,11 @@ ToolRegistry.register({
   name: "list",
   name: "list",
   render(props) {
   render(props) {
     return (
     return (
-      <BasicTool icon="bullet-list" trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}>
+      <BasicTool
+        {...props}
+        icon="bullet-list"
+        trigger={{ title: "List", subtitle: getDirectory(props.input.path || "/") }}
+      >
         <Show when={props.output}>
         <Show when={props.output}>
           {(output) => (
           {(output) => (
             <div data-component="tool-output" data-scrollable>
             <div data-component="tool-output" data-scrollable>
@@ -358,6 +372,7 @@ ToolRegistry.register({
   render(props) {
   render(props) {
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         icon="magnifying-glass-menu"
         icon="magnifying-glass-menu"
         trigger={{
         trigger={{
           title: "Glob",
           title: "Glob",
@@ -385,6 +400,7 @@ ToolRegistry.register({
     if (props.input.include) args.push("include=" + props.input.include)
     if (props.input.include) args.push("include=" + props.input.include)
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         icon="magnifying-glass-menu"
         icon="magnifying-glass-menu"
         trigger={{
         trigger={{
           title: "Grep",
           title: "Grep",
@@ -409,6 +425,7 @@ ToolRegistry.register({
   render(props) {
   render(props) {
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         icon="window-cursor"
         icon="window-cursor"
         trigger={{
         trigger={{
           title: "Webfetch",
           title: "Webfetch",
@@ -438,6 +455,7 @@ ToolRegistry.register({
   render(props) {
   render(props) {
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         icon="task"
         icon="task"
         trigger={{
         trigger={{
           title: `${props.input.subagent_type || props.tool} Agent`,
           title: `${props.input.subagent_type || props.tool} Agent`,
@@ -462,6 +480,7 @@ ToolRegistry.register({
   render(props) {
   render(props) {
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         icon="console"
         icon="console"
         trigger={{
         trigger={{
           title: "Shell",
           title: "Shell",
@@ -485,6 +504,7 @@ ToolRegistry.register({
     const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
     const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         defaultOpen
         defaultOpen
         icon="code-lines"
         icon="code-lines"
         trigger={
         trigger={
@@ -534,6 +554,7 @@ ToolRegistry.register({
     const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
     const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         defaultOpen
         defaultOpen
         icon="code-lines"
         icon="code-lines"
         trigger={
         trigger={
@@ -575,6 +596,7 @@ ToolRegistry.register({
   render(props) {
   render(props) {
     return (
     return (
       <BasicTool
       <BasicTool
+        {...props}
         defaultOpen
         defaultOpen
         icon="checklist"
         icon="checklist"
         trigger={{
         trigger={{

+ 348 - 350
packages/ui/src/components/session-turn.tsx

@@ -7,7 +7,7 @@ import { createEffect, createMemo, For, Match, onCleanup, ParentProps, Show, Swi
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { DiffChanges } from "./diff-changes"
 import { DiffChanges } from "./diff-changes"
 import { Typewriter } from "./typewriter"
 import { Typewriter } from "./typewriter"
-import { Message } from "./message-part"
+import { Message, Part } from "./message-part"
 import { Markdown } from "./markdown"
 import { Markdown } from "./markdown"
 import { Accordion } from "./accordion"
 import { Accordion } from "./accordion"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { StickyAccordionHeader } from "./sticky-accordion-header"
@@ -35,29 +35,133 @@ export function SessionTurn(
 ) {
 ) {
   const data = useData()
   const data = useData()
   const diffComponent = useDiffComponent()
   const diffComponent = useDiffComponent()
-  const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
+  const messages = createMemo(() => data.store.message[props.sessionID] ?? [])
   const userMessages = createMemo(() =>
   const userMessages = createMemo(() =>
     messages()
     messages()
       .filter((m) => m.role === "user")
       .filter((m) => m.role === "user")
       .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 lastUserMessage = createMemo(() => userMessages().at(-1)!)
+  const message = createMemo(() => userMessages().find((m) => m.id === props.messageID)!)
   const status = createMemo(
   const status = createMemo(
     () =>
     () =>
       data.store.session_status[props.sessionID] ?? {
       data.store.session_status[props.sessionID] ?? {
         type: "idle",
         type: "idle",
       },
       },
   )
   )
-  const working = createMemo(() => status()?.type !== "idle" && message()?.id === userMessages().at(-1)?.id)
+  const working = createMemo(() => status().type !== "idle" && message().id === lastUserMessage().id)
   const retry = createMemo(() => {
   const retry = createMemo(() => {
     const s = status()
     const s = status()
     if (s.type !== "retry") return
     if (s.type !== "retry") return
     return s
     return s
   })
   })
 
 
+  const assistantMessages = createMemo(() => {
+    return messages().filter((m) => m.role === "assistant" && m.parentID == message().id) as AssistantMessage[]
+  })
+  const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
+  const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
+  const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
+  const parts = createMemo(() => data.store.part[message().id])
+  const lastTextPart = createMemo(() =>
+    assistantParts()
+      .filter((p) => p?.type === "text")
+      .at(-1),
+  )
+  const summary = createMemo(() => message().summary?.body)
+  const response = createMemo(() => lastTextPart()?.text)
+
+  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
+  })
+  const hasDiffs = createMemo(() => message().summary?.diffs?.length)
+  const isShellMode = createMemo(() => {
+    if (parts().some((p) => p.type !== "text" || !p.synthetic)) return false
+    if (assistantParts().length !== 1) return false
+    const assistantPart = assistantParts()[0]
+    if (assistantPart.type !== "tool") return false
+    if (assistantPart.tool !== "bash") return false
+    return true
+  })
+
+  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,
+    })
+  }
+
   let scrollRef: HTMLDivElement | undefined
   let scrollRef: HTMLDivElement | undefined
   let lastScrollTop = 0
   let lastScrollTop = 0
-  const [state, setState] = createStore({
+  const [store, setStore] = createStore({
     contentRef: undefined as HTMLDivElement | undefined,
     contentRef: undefined as HTMLDivElement | undefined,
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
@@ -65,418 +169,312 @@ export function SessionTurn(
     userScrolled: false,
     userScrolled: false,
     stickyHeaderHeight: 0,
     stickyHeaderHeight: 0,
     retrySeconds: 0,
     retrySeconds: 0,
+    status: rawStatus(),
+    stepsExpanded: props.stepsExpanded ?? working(),
+    duration: duration(),
   })
   })
 
 
   createEffect(() => {
   createEffect(() => {
     const r = retry()
     const r = retry()
     if (!r) {
     if (!r) {
-      setState("retrySeconds", 0)
+      setStore("retrySeconds", 0)
       return
       return
     }
     }
     const updateSeconds = () => {
     const updateSeconds = () => {
       const next = r.next
       const next = r.next
-      if (next) setState("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
+      if (next) setStore("retrySeconds", Math.max(0, Math.round((next - Date.now()) / 1000)))
     }
     }
     updateSeconds()
     updateSeconds()
-
     const timer = setInterval(updateSeconds, 1000)
     const timer = setInterval(updateSeconds, 1000)
     onCleanup(() => clearInterval(timer))
     onCleanup(() => clearInterval(timer))
   })
   })
 
 
   function handleScroll() {
   function handleScroll() {
-    if (!scrollRef || state.autoScrolled) return
+    if (!scrollRef || store.autoScrolled) return
     const { scrollTop } = scrollRef
     const { scrollTop } = scrollRef
     // only mark as user scrolled if they actively scrolled upward
     // only mark as user scrolled if they actively scrolled upward
     // content growth increases scrollHeight but never decreases scrollTop
     // content growth increases scrollHeight but never decreases scrollTop
     const scrolledUp = scrollTop < lastScrollTop - 10
     const scrolledUp = scrollTop < lastScrollTop - 10
     if (scrolledUp && working()) {
     if (scrolledUp && working()) {
-      setState("userScrolled", true)
+      setStore("userScrolled", true)
     }
     }
     lastScrollTop = scrollTop
     lastScrollTop = scrollTop
   }
   }
 
 
   function handleInteraction() {
   function handleInteraction() {
-    if (working()) {
-      setState("userScrolled", true)
-    }
+    if (working()) setStore("userScrolled", true)
   }
   }
 
 
   function scrollToBottom() {
   function scrollToBottom() {
-    if (!scrollRef || state.userScrolled || !working()) return
-    setState("autoScrolled", true)
+    if (!scrollRef || store.userScrolled || !working()) return
+    setStore("autoScrolled", true)
     requestAnimationFrame(() => {
     requestAnimationFrame(() => {
       scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
       scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
       requestAnimationFrame(() => {
       requestAnimationFrame(() => {
         lastScrollTop = scrollRef?.scrollTop ?? 0
         lastScrollTop = scrollRef?.scrollTop ?? 0
-        setState("autoScrolled", false)
+        setStore("autoScrolled", false)
       })
       })
     })
     })
   }
   }
 
 
-  createResizeObserver(() => state.contentRef, scrollToBottom)
+  createResizeObserver(() => store.contentRef, scrollToBottom)
 
 
   createEffect(() => {
   createEffect(() => {
-    if (!working()) {
-      setState("userScrolled", false)
-    }
+    if (!working()) 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}>
-      <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,
-                })
-              }
-
-              const [store, setStore] = createStore({
-                status: rawStatus(),
-                stepsExpanded: props.stepsExpanded ?? working(),
-                duration: duration(),
-              })
-
-              createEffect(() => {
-                if (props.stepsExpanded !== undefined) {
-                  setStore("stepsExpanded", props.stepsExpanded)
-                }
-              })
+  createEffect(() => {
+    if (props.stepsExpanded !== undefined) {
+      setStore("stepsExpanded", props.stepsExpanded)
+    }
+  })
 
 
-              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
+  let lastStatusChange = Date.now()
+  let statusTimeout: number | undefined
+  createEffect(() => {
+    const newStatus = rawStatus()
+    if (newStatus === store.status || !newStatus) return
 
 
-                const timeSinceLastChange = Date.now() - lastStatusChange
+    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
-                }
-              })
+    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) {
-                  setStore("stepsExpanded", true)
-                  props.onStepsExpandedChange?.(true)
-                }
-                if (prev && !isWorking && !state.userScrolled) {
-                  setStore("stepsExpanded", false)
-                  props.onStepsExpandedChange?.(false)
-                }
-                return isWorking
-              }, working())
+  createEffect((prev) => {
+    const isWorking = working()
+    if (!prev && isWorking) {
+      setStore("stepsExpanded", true)
+      props.onStepsExpandedChange?.(true)
+    }
+    if (prev && !isWorking && !store.userScrolled) {
+      setStore("stepsExpanded", false)
+      props.onStepsExpandedChange?.(false)
+    }
+    return isWorking
+  }, working())
 
 
-              return (
-                <div
-                  ref={(el) => setState("contentRef", el)}
-                  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-expandable={assistantMessages().length > 0}
-                      data-slot="session-turn-collapsible-trigger-content"
-                      variant="ghost"
-                      size="small"
-                      onClick={() => {
-                        if (assistantMessages().length === 0) return
-                        const next = !store.stepsExpanded
-                        setStore("stepsExpanded", next)
-                        props.onStepsExpandedChange?.(next)
-                      }}
-                    >
-                      <Show when={working()}>
-                        <Spinner />
-                      </Show>
+  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 onClick={handleInteraction}>
+          <div
+            ref={(el) => setStore("contentRef", el)}
+            data-message={message().id}
+            data-slot="session-turn-message-container"
+            class={props.classes?.container}
+            style={{ "--sticky-header-height": `${store.stickyHeaderHeight}px` }}
+          >
+            <Switch>
+              <Match when={isShellMode()}>
+                <Part part={assistantParts()[0]} message={message()} defaultOpen />
+              </Match>
+              <Match when={true}>
+                {/* 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>
                       <Switch>
-                        <Match when={retry()}>
-                          <span data-slot="session-turn-retry-message">
-                            {(() => {
-                              const r = retry()
-                              if (!r) return ""
-                              return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
-                            })()}
-                          </span>
-                          <span data-slot="session-turn-retry-seconds">
-                            · retrying {state.retrySeconds > 0 ? `in ${state.retrySeconds}s ` : ""}
-                          </span>
-                          <span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
+                        <Match when={working()}>
+                          <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
+                        </Match>
+                        <Match when={true}>
+                          <h1>{message().summary?.title ?? "New message"}</h1>
                         </Match>
                         </Match>
-                        <Match when={working()}>{store.status ?? "Considering next steps"}</Match>
-                        <Match when={store.stepsExpanded}>Hide steps</Match>
-                        <Match when={!store.stepsExpanded}>Show steps</Match>
                       </Switch>
                       </Switch>
-                      <span>·</span>
-                      <span>{store.duration}</span>
-                      <Show when={assistantMessages().length > 0}>
-                        <Icon name="chevron-grabber-vertical" size="small" />
-                      </Show>
-                    </Button>
-                  </div>
-                  {/* Response */}
-                  <Show when={store.stepsExpanded && assistantMessages().length > 0}>
-                    <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>
                     </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">
+                  </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-expandable={assistantMessages().length > 0}
+                    data-slot="session-turn-collapsible-trigger-content"
+                    variant="ghost"
+                    size="small"
+                    onClick={() => {
+                      if (assistantMessages().length === 0) return
+                      const next = !store.stepsExpanded
+                      setStore("stepsExpanded", next)
+                      props.onStepsExpandedChange?.(next)
+                    }}
+                  >
+                    <Show when={working()}>
+                      <Spinner />
+                    </Show>
+                    <Switch>
+                      <Match when={retry()}>
+                        <span data-slot="session-turn-retry-message">
+                          {(() => {
+                            const r = retry()
+                            if (!r) return ""
+                            return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message
+                          })()}
+                        </span>
+                        <span data-slot="session-turn-retry-seconds">
+                          · retrying {store.retrySeconds > 0 ? `in ${store.retrySeconds}s ` : ""}
+                        </span>
+                        <span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
+                      </Match>
+                      <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>
+                    <Show when={assistantMessages().length > 0}>
+                      <Icon name="chevron-grabber-vertical" size="small" />
+                    </Show>
+                  </Button>
+                </div>
+                {/* Response */}
+                <Show when={store.stepsExpanded && assistantMessages().length > 0}>
+                  <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={message().summary?.diffs?.length}>Summary</Match>
-                            <Match when={true}>Response</Match>
+                            <Match when={response() && 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>
-                        </h2>
-                        <Show when={summary()}>
+                        )
+                      }}
+                    </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">
+                      <Switch>
+                        <Match when={summary()}>
                           {(summary) => (
                           {(summary) => (
-                            <Markdown
-                              data-slot="session-turn-markdown"
-                              data-diffs={!!message().summary?.diffs?.length}
-                              text={summary()}
-                            />
+                            <>
+                              <h2 data-slot="session-turn-summary-title">Summary</h2>
+                              <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} 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>
+                        </Match>
+                        <Match when={response()}>
+                          {(response) => (
+                            <>
+                              <h2 data-slot="session-turn-summary-title">Response</h2>
+                              <Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response()} />
+                            </>
                           )}
                           )}
-                        </For>
-                      </Accordion>
+                        </Match>
+                      </Switch>
                     </div>
                     </div>
-                  </Show>
-                  <Show when={error() && !store.stepsExpanded}>
-                    <Card variant="error" class="error-card">
-                      {error()?.data?.message as string}
-                    </Card>
-                  </Show>
-                </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>
+              </Match>
+            </Switch>
+          </div>
           {props.children}
           {props.children}
         </div>
         </div>
       </div>
       </div>