Browse Source

wip(desktop): progress

Adam 2 months ago
parent
commit
6647b1e22f

+ 8 - 23
packages/app/AGENTS.md

@@ -1,28 +1,13 @@
-# Agent Guidelines for @opencode/app
+## Debugging
 
-## Build/Test Commands
+- To test the opencode app, use the playwrite mcp server, the app is already
+  running at http://localhost:3000
+- NEVER try to restart the app, or the server process, EVER.
 
-- **Development**: `bun run dev` (starts Vite dev server on port 3000)
-- **Build**: `bun run build` (production build)
-- **Preview**: `bun run serve` (preview production build)
-- **Validation**: Use `bun run typecheck` only - do not build or run project for validation
-- **Testing**: Do not create or run automated tests
+## SolidJS
 
-## Code Style
+- Always prefer `createStore` over multiple `createSignal` calls
 
-- **Framework**: SolidJS with TypeScript
-- **Imports**: Use `@/` alias for src/ directory (e.g., `import Button from "@/ui/button"`)
-- **Formatting**: Prettier configured with semicolons disabled, 120 character line width
-- **Components**: Use function declarations, splitProps for component props
-- **Types**: Define interfaces for component props, avoid `any` type
-- **CSS**: TailwindCSS with custom CSS variables theme system
-- **Naming**: PascalCase for components, camelCase for variables/functions, snake_case for file names
-- **File Structure**: UI primitives in `/ui/`, higher-level components in `/components/`, pages in `/pages/`, providers in `/providers/`
+## Tool Calling
 
-## Key Dependencies
-
-- SolidJS, @solidjs/router, @kobalte/core (UI primitives)
-- TailwindCSS 4.x with @tailwindcss/vite
-- Custom theme system with CSS variables
-
-No special rules files found.
+- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.

+ 6 - 0
packages/app/src/components/session/index.ts

@@ -0,0 +1,6 @@
+export { SessionHeader } from "./session-header"
+export { SessionContextTab } from "./session-context-tab"
+export { SessionReviewTab } from "./session-review-tab"
+export { SortableTab, FileVisual } from "./session-sortable-tab"
+export { SortableTerminalTab } from "./session-sortable-terminal-tab"
+export { NewSessionView } from "./session-new-view"

+ 419 - 0
packages/app/src/components/session/session-context-tab.tsx

@@ -0,0 +1,419 @@
+import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { useLayout } from "@/context/layout"
+import { checksum } from "@opencode-ai/util/encode"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Accordion } from "@opencode-ai/ui/accordion"
+import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
+import { Code } from "@opencode-ai/ui/code"
+import { Markdown } from "@opencode-ai/ui/markdown"
+import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+
+interface SessionContextTabProps {
+  messages: () => Message[]
+  visibleUserMessages: () => UserMessage[]
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+  info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
+}
+
+export function SessionContextTab(props: SessionContextTabProps) {
+  const params = useParams()
+  const sync = useSync()
+
+  const ctx = createMemo(() => {
+    const last = props.messages().findLast((x) => {
+      if (x.role !== "assistant") return false
+      const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
+      return total > 0
+    }) as AssistantMessage
+    if (!last) return
+
+    const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
+    const model = provider?.models[last.modelID]
+    const limit = model?.limit.context
+
+    const input = last.tokens.input
+    const output = last.tokens.output
+    const reasoning = last.tokens.reasoning
+    const cacheRead = last.tokens.cache.read
+    const cacheWrite = last.tokens.cache.write
+    const total = input + output + reasoning + cacheRead + cacheWrite
+    const usage = limit ? Math.round((total / limit) * 100) : null
+
+    return {
+      message: last,
+      provider,
+      model,
+      limit,
+      input,
+      output,
+      reasoning,
+      cacheRead,
+      cacheWrite,
+      total,
+      usage,
+    }
+  })
+
+  const cost = createMemo(() => {
+    const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
+    return new Intl.NumberFormat("en-US", {
+      style: "currency",
+      currency: "USD",
+    }).format(total)
+  })
+
+  const counts = createMemo(() => {
+    const all = props.messages()
+    const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
+    const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
+    return {
+      all: all.length,
+      user,
+      assistant,
+    }
+  })
+
+  const systemPrompt = createMemo(() => {
+    const msg = props.visibleUserMessages().findLast((m) => !!m.system)
+    const system = msg?.system
+    if (!system) return
+    const trimmed = system.trim()
+    if (!trimmed) return
+    return trimmed
+  })
+
+  const number = (value: number | null | undefined) => {
+    if (value === undefined) return "—"
+    if (value === null) return "—"
+    return value.toLocaleString()
+  }
+
+  const percent = (value: number | null | undefined) => {
+    if (value === undefined) return "—"
+    if (value === null) return "—"
+    return value.toString() + "%"
+  }
+
+  const time = (value: number | undefined) => {
+    if (!value) return "—"
+    return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
+  }
+
+  const providerLabel = createMemo(() => {
+    const c = ctx()
+    if (!c) return "—"
+    return c.provider?.name ?? c.message.providerID
+  })
+
+  const modelLabel = createMemo(() => {
+    const c = ctx()
+    if (!c) return "—"
+    if (c.model?.name) return c.model.name
+    return c.message.modelID
+  })
+
+  const breakdown = createMemo(
+    on(
+      () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
+      () => {
+        const c = ctx()
+        if (!c) return []
+        const input = c.input
+        if (!input) return []
+
+        const out = {
+          system: systemPrompt()?.length ?? 0,
+          user: 0,
+          assistant: 0,
+          tool: 0,
+        }
+
+        for (const msg of props.messages()) {
+          const parts = (sync.data.part[msg.id] ?? []) as Part[]
+
+          if (msg.role === "user") {
+            for (const part of parts) {
+              if (part.type === "text") out.user += part.text.length
+              if (part.type === "file") out.user += part.source?.text.value.length ?? 0
+              if (part.type === "agent") out.user += part.source?.value.length ?? 0
+            }
+            continue
+          }
+
+          if (msg.role === "assistant") {
+            for (const part of parts) {
+              if (part.type === "text") out.assistant += part.text.length
+              if (part.type === "reasoning") out.assistant += part.text.length
+              if (part.type === "tool") {
+                out.tool += Object.keys(part.state.input).length * 16
+                if (part.state.status === "pending") out.tool += part.state.raw.length
+                if (part.state.status === "completed") out.tool += part.state.output.length
+                if (part.state.status === "error") out.tool += part.state.error.length
+              }
+            }
+          }
+        }
+
+        const estimateTokens = (chars: number) => Math.ceil(chars / 4)
+        const system = estimateTokens(out.system)
+        const user = estimateTokens(out.user)
+        const assistant = estimateTokens(out.assistant)
+        const tool = estimateTokens(out.tool)
+        const estimated = system + user + assistant + tool
+
+        const pct = (tokens: number) => (tokens / input) * 100
+        const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
+
+        const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
+          return [
+            {
+              key: "system",
+              label: "System",
+              tokens: tokens.system,
+              width: pct(tokens.system),
+              percent: pctLabel(tokens.system),
+              color: "var(--syntax-info)",
+            },
+            {
+              key: "user",
+              label: "User",
+              tokens: tokens.user,
+              width: pct(tokens.user),
+              percent: pctLabel(tokens.user),
+              color: "var(--syntax-success)",
+            },
+            {
+              key: "assistant",
+              label: "Assistant",
+              tokens: tokens.assistant,
+              width: pct(tokens.assistant),
+              percent: pctLabel(tokens.assistant),
+              color: "var(--syntax-property)",
+            },
+            {
+              key: "tool",
+              label: "Tool Calls",
+              tokens: tokens.tool,
+              width: pct(tokens.tool),
+              percent: pctLabel(tokens.tool),
+              color: "var(--syntax-warning)",
+            },
+            {
+              key: "other",
+              label: "Other",
+              tokens: tokens.other,
+              width: pct(tokens.other),
+              percent: pctLabel(tokens.other),
+              color: "var(--syntax-comment)",
+            },
+          ].filter((x) => x.tokens > 0)
+        }
+
+        if (estimated <= input) {
+          return build({ system, user, assistant, tool, other: input - estimated })
+        }
+
+        const scale = input / estimated
+        const scaled = {
+          system: Math.floor(system * scale),
+          user: Math.floor(user * scale),
+          assistant: Math.floor(assistant * scale),
+          tool: Math.floor(tool * scale),
+        }
+        const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
+        return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
+      },
+    ),
+  )
+
+  function Stat(statProps: { label: string; value: JSX.Element }) {
+    return (
+      <div class="flex flex-col gap-1">
+        <div class="text-12-regular text-text-weak">{statProps.label}</div>
+        <div class="text-12-medium text-text-strong">{statProps.value}</div>
+      </div>
+    )
+  }
+
+  const stats = createMemo(() => {
+    const c = ctx()
+    const count = counts()
+    return [
+      { label: "Session", value: props.info()?.title ?? params.id ?? "—" },
+      { label: "Messages", value: count.all.toLocaleString() },
+      { label: "Provider", value: providerLabel() },
+      { label: "Model", value: modelLabel() },
+      { label: "Context Limit", value: number(c?.limit) },
+      { label: "Total Tokens", value: number(c?.total) },
+      { label: "Usage", value: percent(c?.usage) },
+      { label: "Input Tokens", value: number(c?.input) },
+      { label: "Output Tokens", value: number(c?.output) },
+      { label: "Reasoning Tokens", value: number(c?.reasoning) },
+      { label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
+      { label: "User Messages", value: count.user.toLocaleString() },
+      { label: "Assistant Messages", value: count.assistant.toLocaleString() },
+      { label: "Total Cost", value: cost() },
+      { label: "Session Created", value: time(props.info()?.time.created) },
+      { label: "Last Activity", value: time(c?.message.time.created) },
+    ] satisfies { label: string; value: JSX.Element }[]
+  })
+
+  function RawMessageContent(msgProps: { message: Message }) {
+    const file = createMemo(() => {
+      const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
+      const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
+      return {
+        name: `${msgProps.message.role}-${msgProps.message.id}.json`,
+        contents,
+        cacheKey: checksum(contents),
+      }
+    })
+
+    return <Code file={file()} overflow="wrap" class="select-text" />
+  }
+
+  function RawMessage(msgProps: { message: Message }) {
+    return (
+      <Accordion.Item value={msgProps.message.id}>
+        <StickyAccordionHeader>
+          <Accordion.Trigger>
+            <div class="flex items-center justify-between gap-2 w-full">
+              <div class="min-w-0 truncate">
+                {msgProps.message.role} <span class="text-text-base">• {msgProps.message.id}</span>
+              </div>
+              <div class="flex items-center gap-3">
+                <div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
+                <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
+              </div>
+            </div>
+          </Accordion.Trigger>
+        </StickyAccordionHeader>
+        <Accordion.Content class="bg-background-base">
+          <div class="p-3">
+            <RawMessageContent message={msgProps.message} />
+          </div>
+        </Accordion.Content>
+      </Accordion.Item>
+    )
+  }
+
+  let scroll: HTMLDivElement | undefined
+  let frame: number | undefined
+  let pending: { x: number; y: number } | undefined
+
+  const restoreScroll = () => {
+    const el = scroll
+    if (!el) return
+
+    const s = props.view()?.scroll("context")
+    if (!s) return
+
+    if (el.scrollTop !== s.y) el.scrollTop = s.y
+    if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+  }
+
+  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+    pending = {
+      x: event.currentTarget.scrollLeft,
+      y: event.currentTarget.scrollTop,
+    }
+    if (frame !== undefined) return
+
+    frame = requestAnimationFrame(() => {
+      frame = undefined
+
+      const next = pending
+      pending = undefined
+      if (!next) return
+
+      props.view().setScroll("context", next)
+    })
+  }
+
+  createEffect(
+    on(
+      () => props.messages().length,
+      () => {
+        requestAnimationFrame(restoreScroll)
+      },
+      { defer: true },
+    ),
+  )
+
+  onCleanup(() => {
+    if (frame === undefined) return
+    cancelAnimationFrame(frame)
+  })
+
+  return (
+    <div
+      class="@container h-full overflow-y-auto no-scrollbar pb-10"
+      ref={(el) => {
+        scroll = el
+        restoreScroll()
+      }}
+      onScroll={handleScroll}
+    >
+      <div class="px-6 pt-4 flex flex-col gap-10">
+        <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
+          <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
+        </div>
+
+        <Show when={breakdown().length > 0}>
+          <div class="flex flex-col gap-2">
+            <div class="text-12-regular text-text-weak">Context Breakdown</div>
+            <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
+              <For each={breakdown()}>
+                {(segment) => (
+                  <div
+                    class="h-full"
+                    style={{
+                      width: `${segment.width}%`,
+                      "background-color": segment.color,
+                    }}
+                  />
+                )}
+              </For>
+            </div>
+            <div class="flex flex-wrap gap-x-3 gap-y-1">
+              <For each={breakdown()}>
+                {(segment) => (
+                  <div class="flex items-center gap-1 text-11-regular text-text-weak">
+                    <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
+                    <div>{segment.label}</div>
+                    <div class="text-text-weaker">{segment.percent}</div>
+                  </div>
+                )}
+              </For>
+            </div>
+            <div class="hidden text-11-regular text-text-weaker">
+              Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
+            </div>
+          </div>
+        </Show>
+
+        <Show when={systemPrompt()}>
+          {(prompt) => (
+            <div class="flex flex-col gap-2">
+              <div class="text-12-regular text-text-weak">System Prompt</div>
+              <div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
+                <Markdown text={prompt()} class="text-12-regular" />
+              </div>
+            </div>
+          )}
+        </Show>
+
+        <div class="flex flex-col gap-2">
+          <div class="text-12-regular text-text-weak">Raw messages</div>
+          <Accordion multiple>
+            <For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
+          </Accordion>
+        </div>
+      </div>
+    </div>
+  )
+}

+ 212 - 0
packages/app/src/components/session/session-header.tsx

@@ -0,0 +1,212 @@
+import { createMemo, createResource, Show } from "solid-js"
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useLayout } from "@/context/layout"
+import { useCommand } from "@/context/command"
+import { useServer } from "@/context/server"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useSync } from "@/context/sync"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { getFilename } from "@opencode-ai/util/path"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { iife } from "@opencode-ai/util/iife"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Button } from "@opencode-ai/ui/button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { Select } from "@opencode-ai/ui/select"
+import { Popover } from "@opencode-ai/ui/popover"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { SessionLspIndicator } from "@/components/session-lsp-indicator"
+import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
+import type { Session } from "@opencode-ai/sdk/v2/client"
+
+export function SessionHeader() {
+  const globalSDK = useGlobalSDK()
+  const layout = useLayout()
+  const params = useParams()
+  const navigate = useNavigate()
+  const command = useCommand()
+  const server = useServer()
+  const dialog = useDialog()
+  const sync = useSync()
+
+  const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
+  const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
+  const branch = createMemo(() => sync.data.vcs?.branch)
+
+  function navigateToProject(directory: string) {
+    navigate(`/${base64Encode(directory)}`)
+  }
+
+  function navigateToSession(session: Session | undefined) {
+    if (!session) return
+    navigate(`/${params.dir}/session/${session.id}`)
+  }
+
+  return (
+    <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+      <button
+        type="button"
+        class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
+        onClick={layout.mobileSidebar.toggle}
+      >
+        <Icon name="menu" size="small" />
+      </button>
+      <div class="px-4 flex items-center justify-between gap-4 w-full">
+        <div class="flex items-center gap-3 min-w-0">
+          <div class="flex items-center gap-2 min-w-0">
+            <div class="hidden xl:flex items-center gap-2">
+              <Select
+                options={layout.projects.list().map((project) => project.worktree)}
+                current={sync.directory}
+                label={(x) => {
+                  const name = getFilename(x)
+                  const b = x === sync.directory ? branch() : undefined
+                  return b ? `${name}:${b}` : name
+                }}
+                onSelect={(x) => (x ? navigateToProject(x) : undefined)}
+                class="text-14-regular text-text-base"
+                variant="ghost"
+              >
+                {/* @ts-ignore */}
+                {(i) => (
+                  <div class="flex items-center gap-2">
+                    <Icon name="folder" size="small" />
+                    <div class="text-text-strong">{getFilename(i)}</div>
+                  </div>
+                )}
+              </Select>
+              <div class="text-text-weaker">/</div>
+            </div>
+            <Select
+              options={sessions()}
+              current={currentSession()}
+              placeholder="New session"
+              label={(x) => x.title}
+              value={(x) => x.id}
+              onSelect={navigateToSession}
+              class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
+              variant="ghost"
+            />
+          </div>
+          <Show when={currentSession()}>
+            <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
+              <IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
+            </TooltipKeybind>
+          </Show>
+        </div>
+        <div class="flex items-center gap-3">
+          <div class="hidden md:flex items-center gap-1">
+            <Button
+              size="small"
+              variant="ghost"
+              onClick={() => {
+                dialog.show(() => <DialogSelectServer />)
+              }}
+            >
+              <div
+                classList={{
+                  "size-1.5 rounded-full": true,
+                  "bg-icon-success-base": server.healthy() === true,
+                  "bg-icon-critical-base": server.healthy() === false,
+                  "bg-border-weak-base": server.healthy() === undefined,
+                }}
+              />
+              <Icon name="server" size="small" class="text-icon-weak" />
+              <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
+            </Button>
+            <SessionLspIndicator />
+            <SessionMcpIndicator />
+          </div>
+          <div class="flex items-center gap-1">
+            <Show when={currentSession()?.summary?.files}>
+              <TooltipKeybind
+                class="hidden md:block shrink-0"
+                title="Toggle review"
+                keybind={command.keybind("review.toggle")}
+              >
+                <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
+                  <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                    <Icon
+                      name={layout.review.opened() ? "layout-right" : "layout-left"}
+                      size="small"
+                      class="group-hover/review-toggle:hidden"
+                    />
+                    <Icon
+                      name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
+                      size="small"
+                      class="hidden group-hover/review-toggle:inline-block"
+                    />
+                    <Icon
+                      name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
+                      size="small"
+                      class="hidden group-active/review-toggle:inline-block"
+                    />
+                  </div>
+                </Button>
+              </TooltipKeybind>
+            </Show>
+            <TooltipKeybind
+              class="hidden md:block shrink-0"
+              title="Toggle terminal"
+              keybind={command.keybind("terminal.toggle")}
+            >
+              <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+                <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                  <Icon
+                    size="small"
+                    name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+                    class="group-hover/terminal-toggle:hidden"
+                  />
+                  <Icon
+                    size="small"
+                    name="layout-bottom-partial"
+                    class="hidden group-hover/terminal-toggle:inline-block"
+                  />
+                  <Icon
+                    size="small"
+                    name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+                    class="hidden group-active/terminal-toggle:inline-block"
+                  />
+                </div>
+              </Button>
+            </TooltipKeybind>
+          </div>
+          <Show when={shareEnabled() && currentSession()}>
+            <Popover
+              title="Share session"
+              trigger={
+                <Tooltip class="shrink-0" value="Share session">
+                  <IconButton icon="share" variant="ghost" class="" />
+                </Tooltip>
+              }
+            >
+              {iife(() => {
+                const [url] = createResource(
+                  () => currentSession(),
+                  async (session) => {
+                    if (!session) return
+                    let shareURL = session.share?.url
+                    if (!shareURL) {
+                      shareURL = await globalSDK.client.session
+                        .share({ sessionID: session.id, directory: sync.directory })
+                        .then((r) => r.data?.share?.url)
+                        .catch((e) => {
+                          console.error("Failed to share session", e)
+                          return undefined
+                        })
+                    }
+                    return shareURL
+                  },
+                )
+                return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
+              })}
+            </Popover>
+          </Show>
+        </div>
+      </div>
+    </header>
+  )
+}

+ 35 - 0
packages/app/src/components/session/session-new-view.tsx

@@ -0,0 +1,35 @@
+import { Show } from "solid-js"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export function NewSessionView() {
+  const sync = useSync()
+
+  return (
+    <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
+      <div class="text-20-medium text-text-weaker">New session</div>
+      <div class="flex justify-center items-center gap-3">
+        <Icon name="folder" size="small" />
+        <div class="text-12-medium text-text-weak">
+          {getDirectory(sync.data.path.directory)}
+          <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+        </div>
+      </div>
+      <Show when={sync.project}>
+        {(project) => (
+          <div class="flex justify-center items-center gap-3">
+            <Icon name="pencil-line" size="small" />
+            <div class="text-12-medium text-text-weak">
+              Last modified&nbsp;
+              <span class="text-text-strong">
+                {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+              </span>
+            </div>
+          </div>
+        )}
+      </Show>
+    </div>
+  )
+}

+ 81 - 0
packages/app/src/components/session/session-review-tab.tsx

@@ -0,0 +1,81 @@
+import { createEffect, on, onCleanup } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import type { FileDiff } from "@opencode-ai/sdk/v2/client"
+
+interface SessionReviewTabProps {
+  diffs: () => FileDiff[]
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+}
+
+export function SessionReviewTab(props: SessionReviewTabProps) {
+  const layout = useLayout()
+
+  let scroll: HTMLDivElement | undefined
+  let frame: number | undefined
+  let pending: { x: number; y: number } | undefined
+
+  const restoreScroll = () => {
+    const el = scroll
+    if (!el) return
+
+    const s = props.view().scroll("review")
+    if (!s) return
+
+    if (el.scrollTop !== s.y) el.scrollTop = s.y
+    if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+  }
+
+  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+    pending = {
+      x: event.currentTarget.scrollLeft,
+      y: event.currentTarget.scrollTop,
+    }
+    if (frame !== undefined) return
+
+    frame = requestAnimationFrame(() => {
+      frame = undefined
+
+      const next = pending
+      pending = undefined
+      if (!next) return
+
+      props.view().setScroll("review", next)
+    })
+  }
+
+  createEffect(
+    on(
+      () => props.diffs().length,
+      () => {
+        requestAnimationFrame(restoreScroll)
+      },
+      { defer: true },
+    ),
+  )
+
+  onCleanup(() => {
+    if (frame === undefined) return
+    cancelAnimationFrame(frame)
+  })
+
+  return (
+    <SessionReview
+      scrollRef={(el) => {
+        scroll = el
+        restoreScroll()
+      }}
+      onScroll={handleScroll}
+      open={props.view().review.open()}
+      onOpenChange={props.view().review.setOpen}
+      classes={{
+        root: "pb-40",
+        header: "px-6",
+        container: "px-6",
+      }}
+      diffs={props.diffs()}
+      diffStyle={layout.review.diffStyle()}
+      onDiffStyleChange={layout.review.setDiffStyle}
+    />
+  )
+}

+ 48 - 0
packages/app/src/components/session/session-sortable-tab.tsx

@@ -0,0 +1,48 @@
+import { createMemo, Show } from "solid-js"
+import type { JSX } from "solid-js"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { getFilename } from "@opencode-ai/util/path"
+import { useFile } from "@/context/file"
+
+export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
+  return (
+    <div class="flex items-center gap-x-1.5">
+      <FileIcon
+        node={{ path: props.path, type: "file" }}
+        classList={{
+          "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
+          "grayscale-0": props.active,
+        }}
+      />
+      <span class="text-14-medium">{getFilename(props.path)}</span>
+    </div>
+  )
+}
+
+export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
+  const file = useFile()
+  const sortable = createSortable(props.tab)
+  const path = createMemo(() => file.pathFromTab(props.tab))
+  return (
+    // @ts-ignore
+    <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
+      <div class="relative h-full">
+        <Tabs.Trigger
+          value={props.tab}
+          closeButton={
+            <Tooltip value="Close tab" placement="bottom">
+              <IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
+            </Tooltip>
+          }
+          hideCloseButton
+        >
+          <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
+        </Tabs.Trigger>
+      </div>
+    </div>
+  )
+}

+ 27 - 0
packages/app/src/components/session/session-sortable-terminal-tab.tsx

@@ -0,0 +1,27 @@
+import type { JSX } from "solid-js"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+
+export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element {
+  const terminal = useTerminal()
+  const sortable = createSortable(props.terminal.id)
+  return (
+    // @ts-ignore
+    <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
+      <div class="relative h-full">
+        <Tabs.Trigger
+          value={props.terminal.id}
+          closeButton={
+            terminal.all().length > 1 && (
+              <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
+            )
+          }
+        >
+          {props.terminal.title}
+        </Tabs.Trigger>
+      </div>
+    </div>
+  )
+}

+ 19 - 777
packages/app/src/pages/session.tsx

@@ -6,7 +6,6 @@ import {
   Match,
   Switch,
   createMemo,
-  createResource,
   createEffect,
   on,
   createRenderEffect,
@@ -19,8 +18,6 @@ import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/f
 import { createStore } from "solid-js/store"
 import { PromptInput } from "@/components/prompt-input"
 import { SessionContextUsage } from "@/components/session-context-usage"
-import { DateTime } from "luxon"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
@@ -32,24 +29,11 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
-import { Markdown } from "@opencode-ai/ui/markdown"
-import { Accordion } from "@opencode-ai/ui/accordion"
-import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
-import { Code } from "@opencode-ai/ui/code"
-import {
-  DragDropProvider,
-  DragDropSensors,
-  DragOverlay,
-  SortableProvider,
-  closestCenter,
-  createSortable,
-} from "@thisbeyond/solid-dnd"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
 import type { DragEvent } from "@thisbeyond/solid-dnd"
-import type { JSX } from "solid-js"
 import { useSync } from "@/context/sync"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { useLayout } from "@/context/layout"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { Terminal } from "@/components/terminal"
 import { checksum } from "@opencode-ai/util/encode"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -57,7 +41,7 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
 import { DialogSelectModel } from "@/components/dialog-select-model"
 import { DialogSelectMcp } from "@/components/dialog-select-mcp"
 import { useCommand } from "@/context/command"
-import { A, useNavigate, useParams } from "@solidjs/router"
+import { useNavigate, useParams } from "@solidjs/router"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
@@ -65,18 +49,15 @@ import { extractPromptFromParts } from "@/utils/prompt"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
 import { usePermission } from "@/context/permission"
 import { showToast } from "@opencode-ai/ui/toast"
-import { useServer } from "@/context/server"
-import { Button } from "@opencode-ai/ui/button"
-import { DialogSelectServer } from "@/components/dialog-select-server"
-import { SessionLspIndicator } from "@/components/session-lsp-indicator"
-import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { Popover } from "@opencode-ai/ui/popover"
-import { Select } from "@opencode-ai/ui/select"
-import { TextField } from "@opencode-ai/ui/text-field"
-import { base64Encode } from "@opencode-ai/util/encode"
-import { iife } from "@opencode-ai/util/iife"
-import { AssistantMessage, Session, type Message, type Part } from "@opencode-ai/sdk/v2/client"
+import {
+  SessionHeader,
+  SessionContextTab,
+  SessionReviewTab,
+  SortableTab,
+  FileVisual,
+  SortableTerminalTab,
+  NewSessionView,
+} from "@/components/session"
 
 function same<T>(a: readonly T[], b: readonly T[]) {
   if (a === b) return true
@@ -84,196 +65,6 @@ function same<T>(a: readonly T[], b: readonly T[]) {
   return a.every((x, i) => x === b[i])
 }
 
-function Header() {
-  const globalSDK = useGlobalSDK()
-  const layout = useLayout()
-  const params = useParams()
-  const navigate = useNavigate()
-  const command = useCommand()
-  const server = useServer()
-  const dialog = useDialog()
-  const sync = useSync()
-
-  const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
-  const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
-  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
-  const branch = createMemo(() => sync.data.vcs?.branch)
-
-  function navigateToProject(directory: string) {
-    navigate(`/${base64Encode(directory)}`)
-  }
-
-  function navigateToSession(session: Session | undefined) {
-    if (!session) return
-    navigate(`/${params.dir}/session/${session.id}`)
-  }
-
-  return (
-    <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
-      <button
-        type="button"
-        class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
-        onClick={layout.mobileSidebar.toggle}
-      >
-        <Icon name="menu" size="small" />
-      </button>
-      <div class="px-4 flex items-center justify-between gap-4 w-full">
-        <div class="flex items-center gap-3 min-w-0">
-          <div class="flex items-center gap-2 min-w-0">
-            <div class="hidden xl:flex items-center gap-2">
-              <Select
-                options={layout.projects.list().map((project) => project.worktree)}
-                current={sync.directory}
-                label={(x) => {
-                  const name = getFilename(x)
-                  const b = x === sync.directory ? branch() : undefined
-                  return b ? `${name}:${b}` : name
-                }}
-                onSelect={(x) => (x ? navigateToProject(x) : undefined)}
-                class="text-14-regular text-text-base"
-                variant="ghost"
-              >
-                {/* @ts-ignore */}
-                {(i) => (
-                  <div class="flex items-center gap-2">
-                    <Icon name="folder" size="small" />
-                    <div class="text-text-strong">{getFilename(i)}</div>
-                  </div>
-                )}
-              </Select>
-              <div class="text-text-weaker">/</div>
-            </div>
-            <Select
-              options={sessions()}
-              current={currentSession()}
-              placeholder="New session"
-              label={(x) => x.title}
-              value={(x) => x.id}
-              onSelect={navigateToSession}
-              class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
-              variant="ghost"
-            />
-          </div>
-          <Show when={currentSession()}>
-            <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
-              <IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
-            </TooltipKeybind>
-          </Show>
-        </div>
-        <div class="flex items-center gap-3">
-          <div class="hidden md:flex items-center gap-1">
-            <Button
-              size="small"
-              variant="ghost"
-              onClick={() => {
-                dialog.show(() => <DialogSelectServer />)
-              }}
-            >
-              <div
-                classList={{
-                  "size-1.5 rounded-full": true,
-                  "bg-icon-success-base": server.healthy() === true,
-                  "bg-icon-critical-base": server.healthy() === false,
-                  "bg-border-weak-base": server.healthy() === undefined,
-                }}
-              />
-              <Icon name="server" size="small" class="text-icon-weak" />
-              <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
-            </Button>
-            <SessionLspIndicator />
-            <SessionMcpIndicator />
-          </div>
-          <div class="flex items-center gap-1">
-            <Show when={currentSession()?.summary?.files}>
-              <TooltipKeybind
-                class="hidden md:block shrink-0"
-                title="Toggle review"
-                keybind={command.keybind("review.toggle")}
-              >
-                <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
-                  <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                    <Icon
-                      name={layout.review.opened() ? "layout-right" : "layout-left"}
-                      size="small"
-                      class="group-hover/review-toggle:hidden"
-                    />
-                    <Icon
-                      name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
-                      size="small"
-                      class="hidden group-hover/review-toggle:inline-block"
-                    />
-                    <Icon
-                      name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
-                      size="small"
-                      class="hidden group-active/review-toggle:inline-block"
-                    />
-                  </div>
-                </Button>
-              </TooltipKeybind>
-            </Show>
-            <TooltipKeybind
-              class="hidden md:block shrink-0"
-              title="Toggle terminal"
-              keybind={command.keybind("terminal.toggle")}
-            >
-              <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
-                <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                  <Icon
-                    size="small"
-                    name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
-                    class="group-hover/terminal-toggle:hidden"
-                  />
-                  <Icon
-                    size="small"
-                    name="layout-bottom-partial"
-                    class="hidden group-hover/terminal-toggle:inline-block"
-                  />
-                  <Icon
-                    size="small"
-                    name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
-                    class="hidden group-active/terminal-toggle:inline-block"
-                  />
-                </div>
-              </Button>
-            </TooltipKeybind>
-          </div>
-          <Show when={shareEnabled() && currentSession()}>
-            <Popover
-              title="Share session"
-              trigger={
-                <Tooltip class="shrink-0" value="Share session">
-                  <IconButton icon="share" variant="ghost" class="" />
-                </Tooltip>
-              }
-            >
-              {iife(() => {
-                const [url] = createResource(
-                  () => currentSession(),
-                  async (session) => {
-                    if (!session) return
-                    let shareURL = session.share?.url
-                    if (!shareURL) {
-                      shareURL = await globalSDK.client.session
-                        .share({ sessionID: session.id, directory: sync.directory })
-                        .then((r) => r.data?.share?.url)
-                        .catch((e) => {
-                          console.error("Failed to share session", e)
-                          return undefined
-                        })
-                    }
-                    return shareURL
-                  },
-                )
-                return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
-              })}
-            </Popover>
-          </Show>
-        </div>
-      </div>
-    </header>
-  )
-}
-
 export default function Page() {
   const layout = useLayout()
   const local = useLocal()
@@ -757,65 +548,6 @@ export default function Page() {
     setStore("activeTerminalDraggable", undefined)
   }
 
-  const SortableTerminalTab = (props: { terminal: LocalPTY }): JSX.Element => {
-    const sortable = createSortable(props.terminal.id)
-    return (
-      // @ts-ignore
-      <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
-        <div class="relative h-full">
-          <Tabs.Trigger
-            value={props.terminal.id}
-            closeButton={
-              terminal.all().length > 1 && (
-                <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
-              )
-            }
-          >
-            {props.terminal.title}
-          </Tabs.Trigger>
-        </div>
-      </div>
-    )
-  }
-
-  const FileVisual = (props: { path: string; active?: boolean }): JSX.Element => {
-    return (
-      <div class="flex items-center gap-x-1.5">
-        <FileIcon
-          node={{ path: props.path, type: "file" }}
-          classList={{
-            "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
-            "grayscale-0": props.active,
-          }}
-        />
-        <span class="text-14-medium">{getFilename(props.path)}</span>
-      </div>
-    )
-  }
-
-  const SortableTab = (props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element => {
-    const sortable = createSortable(props.tab)
-    const path = createMemo(() => file.pathFromTab(props.tab))
-    return (
-      // @ts-ignore
-      <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
-        <div class="relative h-full">
-          <Tabs.Trigger
-            value={props.tab}
-            closeButton={
-              <Tooltip value="Close tab" placement="bottom">
-                <IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
-              </Tooltip>
-            }
-            hideCloseButton
-          >
-            <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
-          </Tabs.Trigger>
-        </div>
-      </div>
-    )
-  }
-
   const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
   const openedTabs = createMemo(() =>
     tabs()
@@ -883,32 +615,6 @@ export default function Page() {
     </div>
   )
 
-  const NewSessionView = () => (
-    <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
-      <div class="text-20-medium text-text-weaker">New session</div>
-      <div class="flex justify-center items-center gap-3">
-        <Icon name="folder" size="small" />
-        <div class="text-12-medium text-text-weak">
-          {getDirectory(sync.data.path.directory)}
-          <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
-        </div>
-      </div>
-      <Show when={sync.project}>
-        {(project) => (
-          <div class="flex justify-center items-center gap-3">
-            <Icon name="pencil-line" size="small" />
-            <div class="text-12-medium text-text-weak">
-              Last modified&nbsp;
-              <span class="text-text-strong">
-                {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
-              </span>
-            </div>
-          </div>
-        )}
-      </Show>
-    </div>
-  )
-
   const DesktopSessionContent = () => (
     <Switch>
       <Match when={params.id}>
@@ -944,478 +650,9 @@ export default function Page() {
     </Switch>
   )
 
-  const ContextTab = () => {
-    const ctx = createMemo(() => {
-      const last = messages().findLast((x) => {
-        if (x.role !== "assistant") return false
-        const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
-        return total > 0
-      }) as AssistantMessage
-      if (!last) return
-
-      const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
-      const model = provider?.models[last.modelID]
-      const limit = model?.limit.context
-
-      const input = last.tokens.input
-      const output = last.tokens.output
-      const reasoning = last.tokens.reasoning
-      const cacheRead = last.tokens.cache.read
-      const cacheWrite = last.tokens.cache.write
-      const total = input + output + reasoning + cacheRead + cacheWrite
-      const usage = limit ? Math.round((total / limit) * 100) : null
-
-      return {
-        message: last,
-        provider,
-        model,
-        limit,
-        input,
-        output,
-        reasoning,
-        cacheRead,
-        cacheWrite,
-        total,
-        usage,
-      }
-    })
-
-    const cost = createMemo(() => {
-      const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
-      return new Intl.NumberFormat("en-US", {
-        style: "currency",
-        currency: "USD",
-      }).format(total)
-    })
-
-    const counts = createMemo(() => {
-      const all = messages()
-      const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
-      const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
-      return {
-        all: all.length,
-        user,
-        assistant,
-      }
-    })
-
-    const systemPrompt = createMemo(() => {
-      const msg = visibleUserMessages().findLast((m) => !!m.system)
-      const system = msg?.system
-      if (!system) return
-      const trimmed = system.trim()
-      if (!trimmed) return
-      return trimmed
-    })
-
-    const number = (value: number | null | undefined) => {
-      if (value === undefined) return "—"
-      if (value === null) return "—"
-      return value.toLocaleString()
-    }
-
-    const percent = (value: number | null | undefined) => {
-      if (value === undefined) return "—"
-      if (value === null) return "—"
-      return value.toString() + "%"
-    }
-
-    const time = (value: number | undefined) => {
-      if (!value) return "—"
-      return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
-    }
-
-    const providerLabel = createMemo(() => {
-      const c = ctx()
-      if (!c) return "—"
-      return c.provider?.name ?? c.message.providerID
-    })
-
-    const modelLabel = createMemo(() => {
-      const c = ctx()
-      if (!c) return "—"
-      if (c.model?.name) return c.model.name
-      return c.message.modelID
-    })
-
-    const breakdown = createMemo(
-      on(
-        () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
-        () => {
-          const c = ctx()
-          if (!c) return []
-          const input = c.input
-          if (!input) return []
-
-          const out = {
-            system: systemPrompt()?.length ?? 0,
-            user: 0,
-            assistant: 0,
-            tool: 0,
-          }
-
-          for (const msg of messages()) {
-            const parts = (sync.data.part[msg.id] ?? []) as Part[]
-
-            if (msg.role === "user") {
-              for (const part of parts) {
-                if (part.type === "text") out.user += part.text.length
-                if (part.type === "file") out.user += part.source?.text.value.length ?? 0
-                if (part.type === "agent") out.user += part.source?.value.length ?? 0
-              }
-              continue
-            }
-
-            if (msg.role === "assistant") {
-              for (const part of parts) {
-                if (part.type === "text") out.assistant += part.text.length
-                if (part.type === "reasoning") out.assistant += part.text.length
-                if (part.type === "tool") {
-                  out.tool += Object.keys(part.state.input).length * 16
-                  if (part.state.status === "pending") out.tool += part.state.raw.length
-                  if (part.state.status === "completed") out.tool += part.state.output.length
-                  if (part.state.status === "error") out.tool += part.state.error.length
-                }
-              }
-            }
-          }
-
-          const estimateTokens = (chars: number) => Math.ceil(chars / 4)
-          const system = estimateTokens(out.system)
-          const user = estimateTokens(out.user)
-          const assistant = estimateTokens(out.assistant)
-          const tool = estimateTokens(out.tool)
-          const estimated = system + user + assistant + tool
-
-          const pct = (tokens: number) => (tokens / input) * 100
-          const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
-
-          const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
-            return [
-              {
-                key: "system",
-                label: "System",
-                tokens: tokens.system,
-                width: pct(tokens.system),
-                percent: pctLabel(tokens.system),
-                color: "var(--syntax-info)",
-              },
-              {
-                key: "user",
-                label: "User",
-                tokens: tokens.user,
-                width: pct(tokens.user),
-                percent: pctLabel(tokens.user),
-                color: "var(--syntax-success)",
-              },
-              {
-                key: "assistant",
-                label: "Assistant",
-                tokens: tokens.assistant,
-                width: pct(tokens.assistant),
-                percent: pctLabel(tokens.assistant),
-                color: "var(--syntax-property)",
-              },
-              {
-                key: "tool",
-                label: "Tool Calls",
-                tokens: tokens.tool,
-                width: pct(tokens.tool),
-                percent: pctLabel(tokens.tool),
-                color: "var(--syntax-warning)",
-              },
-              {
-                key: "other",
-                label: "Other",
-                tokens: tokens.other,
-                width: pct(tokens.other),
-                percent: pctLabel(tokens.other),
-                color: "var(--syntax-comment)",
-              },
-            ].filter((x) => x.tokens > 0)
-          }
-
-          if (estimated <= input) {
-            return build({ system, user, assistant, tool, other: input - estimated })
-          }
-
-          const scale = input / estimated
-          const scaled = {
-            system: Math.floor(system * scale),
-            user: Math.floor(user * scale),
-            assistant: Math.floor(assistant * scale),
-            tool: Math.floor(tool * scale),
-          }
-          const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
-          return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
-        },
-      ),
-    )
-
-    function Stat(props: { label: string; value: JSX.Element }) {
-      return (
-        <div class="flex flex-col gap-1">
-          <div class="text-12-regular text-text-weak">{props.label}</div>
-          <div class="text-12-medium text-text-strong">{props.value}</div>
-        </div>
-      )
-    }
-
-    const stats = createMemo(() => {
-      const c = ctx()
-      const count = counts()
-      return [
-        { label: "Session", value: info()?.title ?? params.id ?? "—" },
-        { label: "Messages", value: count.all.toLocaleString() },
-        { label: "Provider", value: providerLabel() },
-        { label: "Model", value: modelLabel() },
-        { label: "Context Limit", value: number(c?.limit) },
-        { label: "Total Tokens", value: number(c?.total) },
-        { label: "Usage", value: percent(c?.usage) },
-        { label: "Input Tokens", value: number(c?.input) },
-        { label: "Output Tokens", value: number(c?.output) },
-        { label: "Reasoning Tokens", value: number(c?.reasoning) },
-        { label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
-        { label: "User Messages", value: count.user.toLocaleString() },
-        { label: "Assistant Messages", value: count.assistant.toLocaleString() },
-        { label: "Total Cost", value: cost() },
-        { label: "Session Created", value: time(info()?.time.created) },
-        { label: "Last Activity", value: time(c?.message.time.created) },
-      ] satisfies { label: string; value: JSX.Element }[]
-    })
-
-    function RawMessageContent(props: { message: Message }) {
-      const file = createMemo(() => {
-        const parts = (sync.data.part[props.message.id] ?? []) as Part[]
-        const contents = JSON.stringify({ message: props.message, parts }, null, 2)
-        return {
-          name: `${props.message.role}-${props.message.id}.json`,
-          contents,
-          cacheKey: checksum(contents),
-        }
-      })
-
-      return <Code file={file()} overflow="wrap" class="select-text" />
-    }
-
-    function RawMessage(props: { message: Message }) {
-      return (
-        <Accordion.Item value={props.message.id}>
-          <StickyAccordionHeader>
-            <Accordion.Trigger>
-              <div class="flex items-center justify-between gap-2 w-full">
-                <div class="min-w-0 truncate">
-                  {props.message.role} <span class="text-text-base">• {props.message.id}</span>
-                </div>
-                <div class="flex items-center gap-3">
-                  <div class="shrink-0 text-12-regular text-text-weak">{time(props.message.time.created)}</div>
-                  <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
-                </div>
-              </div>
-            </Accordion.Trigger>
-          </StickyAccordionHeader>
-          <Accordion.Content class="bg-background-base">
-            <div class="p-3">
-              <RawMessageContent message={props.message} />
-            </div>
-          </Accordion.Content>
-        </Accordion.Item>
-      )
-    }
-
-    let scroll: HTMLDivElement | undefined
-    let frame: number | undefined
-    let pending: { x: number; y: number } | undefined
-
-    const restoreScroll = () => {
-      const el = scroll
-      if (!el) return
-
-      const s = view()?.scroll("context")
-      if (!s) return
-
-      if (el.scrollTop !== s.y) el.scrollTop = s.y
-      if (el.scrollLeft !== s.x) el.scrollLeft = s.x
-    }
-
-    const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-      pending = {
-        x: event.currentTarget.scrollLeft,
-        y: event.currentTarget.scrollTop,
-      }
-      if (frame !== undefined) return
-
-      frame = requestAnimationFrame(() => {
-        frame = undefined
-
-        const next = pending
-        pending = undefined
-        if (!next) return
-
-        view().setScroll("context", next)
-      })
-    }
-
-    createEffect(
-      on(
-        () => messages().length,
-        () => {
-          requestAnimationFrame(restoreScroll)
-        },
-        { defer: true },
-      ),
-    )
-
-    onCleanup(() => {
-      if (frame === undefined) return
-      cancelAnimationFrame(frame)
-    })
-
-    return (
-      <div
-        class="@container h-full overflow-y-auto no-scrollbar pb-10"
-        ref={(el) => {
-          scroll = el
-          restoreScroll()
-        }}
-        onScroll={handleScroll}
-      >
-        <div class="px-6 pt-4 flex flex-col gap-10">
-          <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
-            <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
-          </div>
-
-          <Show when={breakdown().length > 0}>
-            <div class="flex flex-col gap-2">
-              <div class="text-12-regular text-text-weak">Context Breakdown</div>
-              <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
-                <For each={breakdown()}>
-                  {(segment) => (
-                    <div
-                      class="h-full"
-                      style={{
-                        width: `${segment.width}%`,
-                        "background-color": segment.color,
-                      }}
-                    />
-                  )}
-                </For>
-              </div>
-              <div class="flex flex-wrap gap-x-3 gap-y-1">
-                <For each={breakdown()}>
-                  {(segment) => (
-                    <div class="flex items-center gap-1 text-11-regular text-text-weak">
-                      <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
-                      <div>{segment.label}</div>
-                      <div class="text-text-weaker">{segment.percent}</div>
-                    </div>
-                  )}
-                </For>
-              </div>
-              <div class="hidden text-11-regular text-text-weaker">
-                Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
-              </div>
-            </div>
-          </Show>
-
-          <Show when={systemPrompt()}>
-            {(prompt) => (
-              <div class="flex flex-col gap-2">
-                <div class="text-12-regular text-text-weak">System Prompt</div>
-                <div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
-                  <Markdown text={prompt()} class="text-12-regular" />
-                </div>
-              </div>
-            )}
-          </Show>
-
-          <div class="flex flex-col gap-2">
-            <div class="text-12-regular text-text-weak">Raw messages</div>
-            <Accordion multiple>
-              <For each={messages()}>{(message) => <RawMessage message={message} />}</For>
-            </Accordion>
-          </div>
-        </div>
-      </div>
-    )
-  }
-
-  const ReviewTab = () => {
-    let scroll: HTMLDivElement | undefined
-    let frame: number | undefined
-    let pending: { x: number; y: number } | undefined
-
-    const restoreScroll = () => {
-      const el = scroll
-      console.log("restoreScroll", el)
-      if (!el) return
-
-      const s = view().scroll("review")
-      console.log("restoreScroll", s)
-      if (!s) return
-
-      console.log("restoreScroll", el.scrollTop, s.y)
-      if (el.scrollTop !== s.y) el.scrollTop = s.y
-      if (el.scrollLeft !== s.x) el.scrollLeft = s.x
-    }
-
-    const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-      pending = {
-        x: event.currentTarget.scrollLeft,
-        y: event.currentTarget.scrollTop,
-      }
-      if (frame !== undefined) return
-
-      frame = requestAnimationFrame(() => {
-        frame = undefined
-
-        const next = pending
-        pending = undefined
-        if (!next) return
-
-        view().setScroll("review", next)
-      })
-    }
-
-    createEffect(
-      on(
-        () => diffs().length,
-        () => {
-          requestAnimationFrame(restoreScroll)
-        },
-        { defer: true },
-      ),
-    )
-
-    onCleanup(() => {
-      if (frame === undefined) return
-      cancelAnimationFrame(frame)
-    })
-
-    return (
-      <SessionReview
-        scrollRef={(el) => {
-          scroll = el
-          restoreScroll()
-        }}
-        onScroll={handleScroll}
-        open={view().review.open()}
-        onOpenChange={view().review.setOpen}
-        classes={{
-          root: "pb-40",
-          header: "px-6",
-          container: "px-6",
-        }}
-        diffs={diffs()}
-        diffStyle={layout.review.diffStyle()}
-        onDiffStyleChange={layout.review.setDiffStyle}
-      />
-    )
-  }
-
   return (
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
-      <Header />
+      <SessionHeader />
       <div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
         <Switch>
           <Match when={!params.id}>
@@ -1572,14 +809,19 @@ export default function Page() {
                 <Show when={reviewTab()}>
                   <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
                     <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                      <ReviewTab />
+                      <SessionReviewTab diffs={diffs} view={view} />
                     </div>
                   </Tabs.Content>
                 </Show>
                 <Show when={contextOpen()}>
                   <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
                     <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                      <ContextTab />
+                      <SessionContextTab
+                        messages={messages}
+                        visibleUserMessages={visibleUserMessages}
+                        view={view}
+                        info={info}
+                      />
                     </div>
                   </Tabs.Content>
                 </Show>