소스 검색

Revert "wip(desktop): session turn state consolidation"

This reverts commit 453f862616dc4d3ac90680581cde279e118b0da1.
Adam 2 달 전
부모
커밋
d31824320e
1개의 변경된 파일317개의 추가작업 그리고 300개의 파일을 삭제
  1. 317 300
      packages/ui/src/components/session-turn.tsx

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

@@ -49,362 +49,379 @@ export function SessionTurn(
   const working = createMemo(() => status()?.type !== "idle")
 
   let scrollRef: HTMLDivElement | undefined
-
-  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({
+  const [state, setState] = createStore({
     stickyTitleRef: undefined as HTMLDivElement | undefined,
     stickyTriggerRef: undefined as HTMLDivElement | undefined,
     userScrolled: false,
     stickyHeaderHeight: 0,
     scrollY: 0,
     autoScrolling: false,
-    status: rawStatus(),
-    stepsExpanded: true,
-    duration: duration(),
-    lastStatusChange: Date.now(),
-    statusTimeout: undefined as number | undefined,
   })
 
   function handleScroll() {
     if (!scrollRef) return
     // prevents scroll loops
     if (working() && scrollRef.scrollTop < 100) return
-    setStore("scrollY", scrollRef.scrollTop)
-    if (store.autoScrolling) return
+    setState("scrollY", scrollRef.scrollTop)
+    if (state.autoScrolling) return
     const { scrollTop, scrollHeight, clientHeight } = scrollRef
     const atBottom = scrollHeight - scrollTop - clientHeight < 50
     if (!atBottom && working()) {
-      setStore("userScrolled", true)
+      setState("userScrolled", true)
     }
   }
 
   function handleInteraction() {
     if (working()) {
-      setStore("userScrolled", true)
+      setState("userScrolled", true)
     }
   }
 
   function scrollToBottom() {
-    if (!scrollRef || store.userScrolled || !working() || store.autoScrolling) return
-    setStore("autoScrolling", true)
+    if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return
+    setState("autoScrolling", true)
     requestAnimationFrame(() => {
       scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" })
       requestAnimationFrame(() => {
-        setStore("autoScrolling", false)
+        setState("autoScrolling", false)
       })
     })
   }
 
   createEffect(() => {
     if (!working()) {
-      setStore("userScrolled", false)
+      setState("userScrolled", false)
     }
   })
 
   createResizeObserver(
-    () => store.stickyTitleRef,
+    () => state.stickyTitleRef,
     ({ height }) => {
-      const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
-      setStore("stickyHeaderHeight", height + triggerHeight + 8)
+      const triggerHeight = state.stickyTriggerRef?.offsetHeight ?? 0
+      setState("stickyHeaderHeight", height + triggerHeight + 8)
     },
   )
 
   createResizeObserver(
-    () => store.stickyTriggerRef,
+    () => state.stickyTriggerRef,
     ({ height }) => {
-      const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
-      setStore("stickyHeaderHeight", titleHeight + height + 8)
+      const titleHeight = state.stickyTitleRef?.offsetHeight ?? 0
+      setState("stickyHeaderHeight", titleHeight + height + 8)
     },
   )
 
-  createEffect(() => {
-    lastPart()
-    scrollToBottom()
-  })
+  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,
+              )
 
-  createEffect(() => {
-    const timer = setInterval(() => {
-      setStore("duration", duration())
-    }, 1000)
-    onCleanup(() => clearInterval(timer))
-  })
+              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
 
-  createEffect(() => {
-    const newStatus = rawStatus()
-    if (newStatus === store.status || !newStatus) return
+                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 timeSinceLastChange = Date.now() - store.lastStatusChange
+              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"]
 
-    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,
-      )
-    }
-  })
+                return interval.toDuration(unit).normalize().toHuman({
+                  notation: "compact",
+                  unitDisplay: "narrow",
+                  compactDisplay: "short",
+                  showZeros: false,
+                })
+              }
 
-  createEffect((prev) => {
-    const isWorking = working()
-    if (prev && !isWorking && !store.userScrolled) {
-      setStore("stepsExpanded", false)
-    }
-    return isWorking
-  }, working())
+              createEffect(() => {
+                lastPart()
+                scrollToBottom()
+              })
 
-  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 (
+              const [store, setStore] = createStore({
+                status: rawStatus(),
+                stepsExpanded: true,
+                duration: duration(),
+              })
+
+              createEffect(() => {
+                const timer = setInterval(() => {
+                  setStore("duration", duration())
+                }, 1000)
+                onCleanup(() => clearInterval(timer))
+              })
+
+              let lastStatusChange = Date.now()
+              let statusTimeout: number | undefined
+              createEffect(() => {
+                const newStatus = rawStatus()
+                if (newStatus === store.status || !newStatus) return
+
+                const timeSinceLastChange = Date.now() - lastStatusChange
+
+                if (timeSinceLastChange >= 2500) {
+                  setStore("status", newStatus)
+                  lastStatusChange = Date.now()
+                  if (statusTimeout) {
+                    clearTimeout(statusTimeout)
+                    statusTimeout = undefined
+                  }
+                } else {
+                  if (statusTimeout) clearTimeout(statusTimeout)
+                  statusTimeout = setTimeout(() => {
+                    setStore("status", rawStatus())
+                    lastStatusChange = Date.now()
+                    statusTimeout = undefined
+                  }, 2500 - timeSinceLastChange) as unknown as number
+                }
+              })
+
+              createEffect((prev) => {
+                const isWorking = working()
+                if (prev && !isWorking && !state.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>
                       <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>
+                        <Match when={working()}>{store.status ?? "Considering next steps"}</Match>
+                        <Match when={store.stepsExpanded}>Hide steps</Match>
+                        <Match when={!store.stepsExpanded}>Show steps</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()}
-                      />
-                    )}
+                      <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>
                   </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>
-            </Show>
-          </div>
+              )
+            }}
+          </Show>
           {props.children}
         </div>
       </div>