Adam 4 месяцев назад
Родитель
Сommit
3eb2db98ed

+ 2 - 2
packages/desktop/src/components/code.tsx

@@ -1,8 +1,8 @@
 import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
 import { bundledLanguages, type BundledLanguage, type ShikiTransformer } from "shiki"
 import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
 import { splitProps, type ComponentProps, createEffect, onMount, onCleanup, createMemo, createResource } from "solid-js"
-import { useLocal, useShiki } from "@/context"
-import type { TextSelection } from "@/context/local"
+import { useLocal, type TextSelection } from "@/context/local"
 import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
 import { getFileExtension, getNodeOffsetInLine, getSelectionInContainer } from "@/utils"
+import { useShiki } from "@/context/shiki"
 
 
 type DefinedSelection = Exclude<TextSelection, undefined>
 type DefinedSelection = Exclude<TextSelection, undefined>
 
 

+ 0 - 252
packages/desktop/src/components/editor-pane.tsx

@@ -1,252 +0,0 @@
-import { For, Match, Show, Switch, createSignal, splitProps } from "solid-js"
-import { IconButton, Tabs, Tooltip } from "@opencode-ai/ui"
-import { FileIcon } from "@/ui"
-import {
-  DragDropProvider,
-  DragDropSensors,
-  DragOverlay,
-  SortableProvider,
-  closestCenter,
-  createSortable,
-  useDragDropContext,
-} from "@thisbeyond/solid-dnd"
-import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
-import type { LocalFile } from "@/context/local"
-import { Code } from "@/components/code"
-import { useLocal } from "@/context"
-import type { JSX } from "solid-js"
-
-interface EditorPaneProps {
-  onFileClick: (file: LocalFile) => void
-}
-
-export default function EditorPane(props: EditorPaneProps): JSX.Element {
-  const [localProps] = splitProps(props, ["onFileClick"])
-  const local = useLocal()
-  const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
-
-  const navigateChange = (dir: 1 | -1) => {
-    const active = local.file.active()
-    if (!active) return
-    const current = local.file.changeIndex(active.path)
-    const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
-    local.file.setChangeIndex(active.path, next)
-  }
-
-  const handleTabChange = (path: string) => {
-    local.file.open(path)
-  }
-
-  const handleTabClose = (file: LocalFile) => {
-    local.file.close(file.path)
-  }
-
-  const handleDragStart = (event: unknown) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    setActiveItem(id)
-  }
-
-  const handleDragOver = (event: DragEvent) => {
-    const { draggable, droppable } = event
-    if (draggable && droppable) {
-      const currentFiles = local.file.opened().map((file) => file.path)
-      const fromIndex = currentFiles.indexOf(draggable.id.toString())
-      const toIndex = currentFiles.indexOf(droppable.id.toString())
-      if (fromIndex !== toIndex) {
-        local.file.move(draggable.id.toString(), toIndex)
-      }
-    }
-  }
-
-  const handleDragEnd = () => {
-    setActiveItem(undefined)
-  }
-
-  return (
-    <DragDropProvider
-      onDragStart={handleDragStart}
-      onDragEnd={handleDragEnd}
-      onDragOver={handleDragOver}
-      collisionDetector={closestCenter}
-    >
-      <DragDropSensors />
-      <ConstrainDragYAxis />
-      <Tabs value={local.file.active()?.path} onChange={handleTabChange}>
-        <div class="sticky top-0 shrink-0 flex">
-          <Tabs.List>
-            <SortableProvider ids={local.file.opened().map((file) => file.path)}>
-              <For each={local.file.opened()}>
-                {(file) => <SortableTab file={file} onTabClick={localProps.onFileClick} onTabClose={handleTabClose} />}
-              </For>
-            </SortableProvider>
-          </Tabs.List>
-          <div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
-            <Show when={local.file.active() && local.file.active()!.content?.diff}>
-              {(() => {
-                const activeFile = local.file.active()!
-                const view = local.file.view(activeFile.path)
-                return (
-                  <div class="flex items-center gap-1">
-                    <Show when={view !== "raw"}>
-                      <div class="mr-1 flex items-center gap-1">
-                        <Tooltip value="Previous change" placement="bottom">
-                          <IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
-                        </Tooltip>
-                        <Tooltip value="Next change" placement="bottom">
-                          <IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
-                        </Tooltip>
-                      </div>
-                    </Show>
-                    <Tooltip value="Raw" placement="bottom">
-                      <IconButton
-                        icon="file-text"
-                        variant="ghost"
-                        classList={{
-                          "text-text": view === "raw",
-                          "text-text-muted/70": view !== "raw",
-                          "bg-background-element": view === "raw",
-                        }}
-                        onClick={() => local.file.setView(activeFile.path, "raw")}
-                      />
-                    </Tooltip>
-                    <Tooltip value="Unified diff" placement="bottom">
-                      <IconButton
-                        icon="checklist"
-                        variant="ghost"
-                        classList={{
-                          "text-text": view === "diff-unified",
-                          "text-text-muted/70": view !== "diff-unified",
-                          "bg-background-element": view === "diff-unified",
-                        }}
-                        onClick={() => local.file.setView(activeFile.path, "diff-unified")}
-                      />
-                    </Tooltip>
-                    <Tooltip value="Split diff" placement="bottom">
-                      <IconButton
-                        icon="columns"
-                        variant="ghost"
-                        classList={{
-                          "text-text": view === "diff-split",
-                          "text-text-muted/70": view !== "diff-split",
-                          "bg-background-element": view === "diff-split",
-                        }}
-                        onClick={() => local.file.setView(activeFile.path, "diff-split")}
-                      />
-                    </Tooltip>
-                  </div>
-                )
-              })()}
-            </Show>
-          </div>
-        </div>
-        <For each={local.file.opened()}>
-          {(file) => (
-            <Tabs.Content value={file.path} class="select-text">
-              {(() => {
-                const view = local.file.view(file.path)
-                const showRaw = view === "raw" || !file.content?.diff
-                const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
-                return <Code path={file.path} code={code} class="[&_code]:pb-60" />
-              })()}
-            </Tabs.Content>
-          )}
-        </For>
-      </Tabs>
-      <DragOverlay>
-        {(() => {
-          const id = activeItem()
-          if (!id) return null
-          const draggedFile = local.file.node(id)
-          if (!draggedFile) return null
-          return (
-            <div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
-              <TabVisual file={draggedFile} />
-            </div>
-          )
-        })()}
-      </DragOverlay>
-    </DragDropProvider>
-  )
-}
-
-function TabVisual(props: { file: LocalFile }): JSX.Element {
-  return (
-    <div class="flex items-center gap-x-1.5">
-      <FileIcon node={props.file} class="" />
-      <span classList={{ "text-xs": true, "text-primary": !!props.file.status?.status, italic: !props.file.pinned }}>
-        {props.file.name}
-      </span>
-      <span class="text-xs opacity-70">
-        <Switch>
-          <Match when={props.file.status?.status === "modified"}>
-            <span class="text-primary">M</span>
-          </Match>
-          <Match when={props.file.status?.status === "added"}>
-            <span class="text-success">A</span>
-          </Match>
-          <Match when={props.file.status?.status === "deleted"}>
-            <span class="text-error">D</span>
-          </Match>
-        </Switch>
-      </span>
-    </div>
-  )
-}
-
-function SortableTab(props: {
-  file: LocalFile
-  onTabClick: (file: LocalFile) => void
-  onTabClose: (file: LocalFile) => void
-}): JSX.Element {
-  const sortable = createSortable(props.file.path)
-
-  return (
-    // @ts-ignore
-    <div use:sortable classList={{ "opacity-0": sortable.isActiveDraggable }}>
-      <Tooltip value={props.file.path} placement="bottom">
-        <div class="relative">
-          <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
-            <TabVisual file={props.file} />
-          </Tabs.Trigger>
-          <IconButton
-            icon="close"
-            class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
-            variant="ghost"
-            onClick={() => props.onTabClose(props.file)}
-          />
-        </div>
-      </Tooltip>
-    </div>
-  )
-}
-
-function ConstrainDragYAxis(): JSX.Element {
-  const context = useDragDropContext()
-  if (!context) return <></>
-  const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
-  const transformer: Transformer = {
-    id: "constrain-y-axis",
-    order: 100,
-    callback: (transform) => ({ ...transform, y: 0 }),
-  }
-  onDragStart((event) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    addTransformer("draggables", id, transformer)
-  })
-  onDragEnd((event) => {
-    const id = getDraggableId(event)
-    if (!id) return
-    removeTransformer("draggables", id, transformer.id)
-  })
-  return <></>
-}
-
-const getDraggableId = (event: unknown): string | undefined => {
-  if (typeof event !== "object" || event === null) return undefined
-  if (!("draggable" in event)) return undefined
-  const draggable = (event as { draggable?: { id?: unknown } }).draggable
-  if (!draggable) return undefined
-  return typeof draggable.id === "string" ? draggable.id : undefined
-}

+ 1 - 2
packages/desktop/src/components/file-tree.tsx

@@ -1,5 +1,4 @@
-import { useLocal } from "@/context"
-import type { LocalFile } from "@/context/local"
+import { useLocal, type LocalFile } from "@/context/local"
 import { Tooltip } from "@opencode-ai/ui"
 import { Tooltip } from "@opencode-ai/ui"
 import { Collapsible, FileIcon } from "@/ui"
 import { Collapsible, FileIcon } from "@/ui"
 import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
 import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"

+ 1 - 1
packages/desktop/src/components/markdown.tsx

@@ -1,4 +1,4 @@
-import { useMarked } from "@/context"
+import { useMarked } from "@/context/marked"
 import { createResource } from "solid-js"
 import { createResource } from "solid-js"
 
 
 function strip(text: string): string {
 function strip(text: string): string {

+ 4 - 5
packages/desktop/src/components/prompt-input.tsx

@@ -1,12 +1,11 @@
-import { useLocal } from "@/context"
-import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
+import { Button, Icon, IconButton, Select, SelectDialog } from "@opencode-ai/ui"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, createMemo, Show, Switch, Match, For } from "solid-js"
+import { createEffect, on, Component, createMemo, Show, For } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { FileIcon } from "@/ui"
 import { FileIcon } from "@/ui"
 import { getDirectory, getFilename } from "@/utils"
 import { getDirectory, getFilename } from "@/utils"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { createFocusSignal } from "@solid-primitives/active-element"
-import { TextSelection } from "@/context/local"
+import { TextSelection, useLocal } from "@/context/local"
 import { DateTime } from "luxon"
 import { DateTime } from "luxon"
 
 
 interface PartBase {
 interface PartBase {
@@ -245,7 +244,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
   }
 
 
   return (
   return (
-    <div class="relative size-full max-w-[640px] _max-h-[320px] flex flex-col gap-3">
+    <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
       <Show when={store.popoverIsOpen}>
       <Show when={store.popoverIsOpen}>
         <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
         <div class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10 overflow-y-auto flex flex-col p-2 pb-0 rounded-2xl border border-border-base bg-surface-raised-stronger-non-alpha shadow-md">
           <For each={flat()}>
           <For each={flat()}>

+ 4 - 3
packages/desktop/src/components/session-timeline.tsx

@@ -1,4 +1,3 @@
-import { useLocal, useSync } from "@/context"
 import { Icon, Tooltip } from "@opencode-ai/ui"
 import { Icon, Tooltip } from "@opencode-ai/ui"
 import { Collapsible } from "@/ui"
 import { Collapsible } from "@/ui"
 import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
 import type { AssistantMessage, Message, Part, ToolPart } from "@opencode-ai/sdk"
@@ -22,6 +21,8 @@ import { createElementSize } from "@solid-primitives/resize-observer"
 import { createScrollPosition } from "@solid-primitives/scroll"
 import { createScrollPosition } from "@solid-primitives/scroll"
 import { ProgressCircle } from "./progress-circle"
 import { ProgressCircle } from "./progress-circle"
 import { pipe, sumBy } from "remeda"
 import { pipe, sumBy } from "remeda"
+import { useSync } from "@/context/sync"
+import { useLocal } from "@/context/local"
 
 
 function Part(props: ParentProps & ComponentProps<"div">) {
 function Part(props: ParentProps & ComponentProps<"div">) {
   const [local, others] = splitProps(props, ["class", "classList", "children"])
   const [local, others] = splitProps(props, ["class", "classList", "children"])
@@ -394,7 +395,7 @@ export default function SessionTimeline(props: { session: string; class?: string
         [props.class ?? ""]: !!props.class,
         [props.class ?? ""]: !!props.class,
       }}
       }}
     >
     >
-      <div class="py-1.5 px-6 flex justify-end items-center self-stretch">
+      <div class="flex justify-end items-center self-stretch">
         <div class="flex items-center gap-6">
         <div class="flex items-center gap-6">
           <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
           <Tooltip value={`${tokens()} Tokens`} class="flex items-center gap-1.5">
             <Show when={context()}>
             <Show when={context()}>
@@ -405,7 +406,7 @@ export default function SessionTimeline(props: { session: string; class?: string
           <div class="text-14-regular text-text-strong text-right">{cost()}</div>
           <div class="text-14-regular text-text-strong text-right">{cost()}</div>
         </div>
         </div>
       </div>
       </div>
-      <ul role="list" class="flex flex-col items-start self-stretch px-6 pt-2 pb-6 gap-1">
+      <ul role="list" class="flex flex-col items-start self-stretch">
         <For each={messagesWithValidParts()}>
         <For each={messagesWithValidParts()}>
           {(message) => (
           {(message) => (
             <div
             <div

+ 0 - 34
packages/desktop/src/context/event.tsx

@@ -1,34 +0,0 @@
-import { createContext, useContext, type ParentProps } from "solid-js"
-import { createEventBus } from "@solid-primitives/event-bus"
-import type { Event as SDKEvent } from "@opencode-ai/sdk"
-import { useSDK } from "@/context"
-
-export type Event = SDKEvent // can extend with custom events later
-
-function init() {
-  const sdk = useSDK()
-  const bus = createEventBus<Event>()
-  sdk.event.subscribe().then(async (events) => {
-    for await (const event of events.stream) {
-      bus.emit(event)
-    }
-  })
-  return bus
-}
-
-type EventContext = ReturnType<typeof init>
-
-const ctx = createContext<EventContext>()
-
-export function EventProvider(props: ParentProps) {
-  const value = init()
-  return <ctx.Provider value={value}>{props.children}</ctx.Provider>
-}
-
-export function useEvent() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useEvent must be used within a EventProvider")
-  }
-  return value
-}

+ 25 - 0
packages/desktop/src/context/helper.tsx

@@ -0,0 +1,25 @@
+import { createContext, Show, useContext, type ParentProps } from "solid-js"
+
+export function createSimpleContext<T, Props extends Record<string, any>>(input: {
+  name: string
+  init: ((input: Props) => T) | (() => T)
+}) {
+  const ctx = createContext<T>()
+
+  return {
+    provider: (props: ParentProps<Props>) => {
+      const init = input.init(props)
+      return (
+        // @ts-expect-error
+        <Show when={init.ready === undefined || init.ready === true}>
+          <ctx.Provider value={init}>{props.children}</ctx.Provider>
+        </Show>
+      )
+    },
+    use() {
+      const value = useContext(ctx)
+      if (!value) throw new Error(`${input.name} context must be used within a context provider`)
+      return value
+    },
+  }
+}

+ 0 - 6
packages/desktop/src/context/index.ts

@@ -1,6 +0,0 @@
-export { EventProvider, useEvent } from "./event"
-export { LocalProvider, useLocal } from "./local"
-export { MarkedProvider, useMarked } from "./marked"
-export { SDKProvider, useSDK } from "./sdk"
-export { ShikiProvider, useShiki } from "./shiki"
-export { SyncProvider, useSync } from "./sync"

+ 545 - 509
packages/desktop/src/context/local.tsx

@@ -1,8 +1,19 @@
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
-import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
-import { uniqueBy } from "remeda"
-import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
-import { useSDK, useEvent, useSync } from "@/context"
+import { batch, createEffect, createMemo } from "solid-js"
+import { pipe, sumBy, uniqueBy } from "remeda"
+import type {
+  FileContent,
+  FileNode,
+  Model,
+  Provider,
+  File as FileStatus,
+  Part,
+  Message,
+  AssistantMessage,
+} from "@opencode-ai/sdk"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
+import { useSync } from "./sync"
 
 
 export type LocalFile = FileNode &
 export type LocalFile = FileNode &
   Partial<{
   Partial<{
@@ -28,542 +39,567 @@ export type ModelKey = { providerID: string; modelID: string }
 export type FileContext = { type: "file"; path: string; selection?: TextSelection }
 export type FileContext = { type: "file"; path: string; selection?: TextSelection }
 export type ContextItem = FileContext
 export type ContextItem = FileContext
 
 
-function init() {
-  const sdk = useSDK()
-  const sync = useSync()
-
-  const agent = (() => {
-    const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
-    const [store, setStore] = createStore<{
-      current: string
-    }>({
-      current: list()[0].name,
-    })
-    return {
-      list,
-      current() {
-        return list().find((x) => x.name === store.current)!
-      },
-      set(name: string | undefined) {
-        setStore("current", name ?? list()[0].name)
-      },
-      move(direction: 1 | -1) {
-        let next = list().findIndex((x) => x.name === store.current) + direction
-        if (next < 0) next = list().length - 1
-        if (next >= list().length) next = 0
-        const value = list()[next]
-        setStore("current", value.name)
-        if (value.model)
-          model.set({
-            providerID: value.model.providerID,
-            modelID: value.model.modelID,
-          })
-      },
-    }
-  })()
-
-  const model = (() => {
-    const list = createMemo(() =>
-      sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
-    )
-    const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
-
-    const [store, setStore] = createStore<{
-      model: Record<string, ModelKey>
-      recent: ModelKey[]
-    }>({
-      model: {},
-      recent: [],
-    })
-
-    const value = localStorage.getItem("model")
-    setStore("recent", JSON.parse(value ?? "[]"))
-    createEffect(() => {
-      localStorage.setItem("model", JSON.stringify(store.recent))
-    })
-
-    const fallback = createMemo(() => {
-      if (store.recent.length) return store.recent[0]
-      const provider = sync.data.provider[0]
-      const model = Object.values(provider.models)[0]
-      return { modelID: model.id, providerID: provider.id }
-    })
-
-    const current = createMemo(() => {
-      const a = agent.current()
-      return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
-    })
-
-    const recent = createMemo(() => store.recent.map(find).filter(Boolean))
-
-    return {
-      list,
-      current,
-      recent,
-      set(model: ModelKey | undefined, options?: { recent?: boolean }) {
-        batch(() => {
-          setStore("model", agent.current().name, model ?? fallback())
-          if (options?.recent && model) {
-            const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
-            if (uniq.length > 5) uniq.pop()
-            setStore("recent", uniq)
-          }
-        })
-      },
-    }
-  })()
-
-  const file = (() => {
-    const [store, setStore] = createStore<{
-      node: Record<string, LocalFile>
-      opened: string[]
-      active?: string
-    }>({
-      node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
-      opened: [],
-    })
-
-    const active = createMemo(() => {
-      if (!store.active) return undefined
-      return store.node[store.active]
-    })
-    const opened = createMemo(() => store.opened.map((x) => store.node[x]))
-    const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
-    const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
-
-    // createEffect((prev: FileStatus[]) => {
-    //   const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
-    //   for (const p of removed) {
-    //     setStore(
-    //       "node",
-    //       p.path,
-    //       produce((draft) => {
-    //         draft.status = undefined
-    //         draft.view = "raw"
-    //       }),
-    //     )
-    //     load(p.path)
-    //   }
-    //   for (const p of sync.data.changes) {
-    //     if (store.node[p.path] === undefined) {
-    //       fetch(p.path).then(() => {
-    //         if (store.node[p.path] === undefined) return
-    //         setStore("node", p.path, "status", p)
-    //       })
-    //     } else {
-    //       setStore("node", p.path, "status", p)
-    //     }
-    //   }
-    //   return sync.data.changes
-    // }, sync.data.changes)
-
-    const changed = (path: string) => {
-      const node = store.node[path]
-      if (node?.status) return true
-      const set = changeset()
-      if (set.has(path)) return true
-      for (const p of set) {
-        if (p.startsWith(path ? path + "/" : "")) return true
+export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
+  name: "Local",
+  init: () => {
+    const sdk = useSDK()
+    const sync = useSync()
+
+    const agent = (() => {
+      const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
+      const [store, setStore] = createStore<{
+        current: string
+      }>({
+        current: list()[0].name,
+      })
+      return {
+        list,
+        current() {
+          return list().find((x) => x.name === store.current)!
+        },
+        set(name: string | undefined) {
+          setStore("current", name ?? list()[0].name)
+        },
+        move(direction: 1 | -1) {
+          let next = list().findIndex((x) => x.name === store.current) + direction
+          if (next < 0) next = list().length - 1
+          if (next >= list().length) next = 0
+          const value = list()[next]
+          setStore("current", value.name)
+          if (value.model)
+            model.set({
+              providerID: value.model.providerID,
+              modelID: value.model.modelID,
+            })
+        },
       }
       }
-      return false
-    }
-
-    const resetNode = (path: string) => {
-      setStore("node", path, {
-        loaded: undefined,
-        pinned: undefined,
-        content: undefined,
-        selection: undefined,
-        scrollTop: undefined,
-        folded: undefined,
-        view: undefined,
-        selectedChange: undefined,
+    })()
+
+    const model = (() => {
+      const list = createMemo(() =>
+        sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
+      )
+      const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
+
+      const [store, setStore] = createStore<{
+        model: Record<string, ModelKey>
+        recent: ModelKey[]
+      }>({
+        model: {},
+        recent: [],
       })
       })
-    }
 
 
-    const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
-
-    const load = async (path: string) => {
-      const relativePath = relative(path)
-      sdk.file.read({ query: { path: relativePath } }).then((x) => {
-        setStore(
-          "node",
-          relativePath,
-          produce((draft) => {
-            draft.loaded = true
-            draft.content = x.data
-          }),
-        )
+      const value = localStorage.getItem("model")
+      setStore("recent", JSON.parse(value ?? "[]"))
+      createEffect(() => {
+        localStorage.setItem("model", JSON.stringify(store.recent))
       })
       })
-    }
 
 
-    const fetch = async (path: string) => {
-      const relativePath = relative(path)
-      const parent = relativePath.split("/").slice(0, -1).join("/")
-      if (parent) {
-        await list(parent)
-      }
-    }
+      const fallback = createMemo(() => {
+        if (store.recent.length) return store.recent[0]
+        const provider = sync.data.provider[0]
+        const model = Object.values(provider.models)[0]
+        return { modelID: model.id, providerID: provider.id }
+      })
 
 
-    const init = async (path: string) => {
-      const relativePath = relative(path)
-      if (!store.node[relativePath]) await fetch(path)
-      if (store.node[relativePath].loaded) return
-      return load(relativePath)
-    }
+      const current = createMemo(() => {
+        const a = agent.current()
+        return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
+      })
 
 
-    const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
-      const relativePath = relative(path)
-      if (!store.node[relativePath]) await fetch(path)
-      setStore("opened", (x) => {
-        if (x.includes(relativePath)) return x
-        return [
-          ...opened()
-            .filter((x) => x.pinned)
-            .map((x) => x.path),
-          relativePath,
-        ]
+      const recent = createMemo(() => store.recent.map(find).filter(Boolean))
+
+      return {
+        list,
+        current,
+        recent,
+        set(model: ModelKey | undefined, options?: { recent?: boolean }) {
+          batch(() => {
+            setStore("model", agent.current().name, model ?? fallback())
+            if (options?.recent && model) {
+              const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
+              if (uniq.length > 5) uniq.pop()
+              setStore("recent", uniq)
+            }
+          })
+        },
+      }
+    })()
+
+    const file = (() => {
+      const [store, setStore] = createStore<{
+        node: Record<string, LocalFile>
+        opened: string[]
+        active?: string
+      }>({
+        node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
+        opened: [],
       })
       })
-      setStore("active", relativePath)
-      context.addActive()
-      if (options?.pinned) setStore("node", path, "pinned", true)
-      if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
-      if (store.node[relativePath].loaded) return
-      return load(relativePath)
-    }
 
 
-    const list = async (path: string) => {
-      return sdk.file.list({ query: { path: path + "/" } }).then((x) => {
-        setStore(
-          "node",
-          produce((draft) => {
-            x.data!.forEach((node) => {
-              if (node.path in draft) return
-              draft[node.path] = node
-            })
-          }),
-        )
+      const active = createMemo(() => {
+        if (!store.active) return undefined
+        return store.node[store.active]
       })
       })
-    }
+      const opened = createMemo(() => store.opened.map((x) => store.node[x]))
+      const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
+      const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
+
+      // createEffect((prev: FileStatus[]) => {
+      //   const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
+      //   for (const p of removed) {
+      //     setStore(
+      //       "node",
+      //       p.path,
+      //       produce((draft) => {
+      //         draft.status = undefined
+      //         draft.view = "raw"
+      //       }),
+      //     )
+      //     load(p.path)
+      //   }
+      //   for (const p of sync.data.changes) {
+      //     if (store.node[p.path] === undefined) {
+      //       fetch(p.path).then(() => {
+      //         if (store.node[p.path] === undefined) return
+      //         setStore("node", p.path, "status", p)
+      //       })
+      //     } else {
+      //       setStore("node", p.path, "status", p)
+      //     }
+      //   }
+      //   return sync.data.changes
+      // }, sync.data.changes)
+
+      const changed = (path: string) => {
+        const node = store.node[path]
+        if (node?.status) return true
+        const set = changeset()
+        if (set.has(path)) return true
+        for (const p of set) {
+          if (p.startsWith(path ? path + "/" : "")) return true
+        }
+        return false
+      }
 
 
-    const search = (query: string) => sdk.find.files({ query: { query } }).then((x) => x.data!)
+      const resetNode = (path: string) => {
+        setStore("node", path, {
+          loaded: undefined,
+          pinned: undefined,
+          content: undefined,
+          selection: undefined,
+          scrollTop: undefined,
+          folded: undefined,
+          view: undefined,
+          selectedChange: undefined,
+        })
+      }
 
 
-    const bus = useEvent()
-    bus.listen((event) => {
-      switch (event.type) {
-        case "message.part.updated":
-          const part = event.properties.part
-          if (part.type === "tool" && part.state.status === "completed") {
-            switch (part.tool) {
-              case "read":
-                break
-              case "edit":
-                // load(part.state.input["filePath"] as string)
-                break
-              default:
-                break
-            }
-          }
-          break
-        case "file.watcher.updated":
-          setTimeout(sync.load.changes, 1000)
-          const relativePath = relative(event.properties.file)
-          if (relativePath.startsWith(".git/")) return
-          load(relativePath)
-          break
+      const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
+
+      const load = async (path: string) => {
+        const relativePath = relative(path)
+        sdk.client.file.read({ query: { path: relativePath } }).then((x) => {
+          setStore(
+            "node",
+            relativePath,
+            produce((draft) => {
+              draft.loaded = true
+              draft.content = x.data
+            }),
+          )
+        })
       }
       }
-    })
-
-    return {
-      active,
-      opened,
-      node: (path: string) => store.node[path],
-      update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
-      open,
-      load,
-      init,
-      close(path: string) {
-        setStore("opened", (opened) => opened.filter((x) => x !== path))
-        if (store.active === path) {
-          const index = store.opened.findIndex((f) => f === path)
-          const previous = store.opened[Math.max(0, index - 1)]
-          setStore("active", previous)
+
+      const fetch = async (path: string) => {
+        const relativePath = relative(path)
+        const parent = relativePath.split("/").slice(0, -1).join("/")
+        if (parent) {
+          await list(parent)
         }
         }
-        resetNode(path)
-      },
-      expand(path: string) {
-        setStore("node", path, "expanded", true)
-        if (store.node[path].loaded) return
-        setStore("node", path, "loaded", true)
-        list(path)
-      },
-      collapse(path: string) {
-        setStore("node", path, "expanded", false)
-      },
-      select(path: string, selection: TextSelection | undefined) {
-        setStore("node", path, "selection", selection)
-      },
-      scroll(path: string, scrollTop: number) {
-        setStore("node", path, "scrollTop", scrollTop)
-      },
-      move(path: string, to: number) {
-        const index = store.opened.findIndex((f) => f === path)
-        if (index === -1) return
-        setStore(
-          "opened",
-          produce((opened) => {
-            opened.splice(to, 0, opened.splice(index, 1)[0])
-          }),
-        )
-        setStore("node", path, "pinned", true)
-      },
-      view(path: string): View {
-        const n = store.node[path]
-        return n && n.view ? n.view : "raw"
-      },
-      setView(path: string, view: View) {
-        setStore("node", path, "view", view)
-      },
-      unfold(path: string, key: string) {
-        setStore("node", path, "folded", (xs) => {
-          const a = xs ?? []
-          if (a.includes(key)) return a
-          return [...a, key]
+      }
+
+      const init = async (path: string) => {
+        const relativePath = relative(path)
+        if (!store.node[relativePath]) await fetch(path)
+        if (store.node[relativePath].loaded) return
+        return load(relativePath)
+      }
+
+      const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => {
+        const relativePath = relative(path)
+        if (!store.node[relativePath]) await fetch(path)
+        setStore("opened", (x) => {
+          if (x.includes(relativePath)) return x
+          return [
+            ...opened()
+              .filter((x) => x.pinned)
+              .map((x) => x.path),
+            relativePath,
+          ]
         })
         })
-      },
-      fold(path: string, key: string) {
-        setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
-      },
-      folded(path: string) {
-        const n = store.node[path]
-        return n && n.folded ? n.folded : []
-      },
-      changeIndex(path: string) {
-        return store.node[path]?.selectedChange
-      },
-      setChangeIndex(path: string, index: number | undefined) {
-        setStore("node", path, "selectedChange", index)
-      },
-      changes,
-      changed,
-      children(path: string) {
-        return Object.values(store.node).filter(
-          (x) =>
-            x.path.startsWith(path) &&
-            x.path !== path &&
-            !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
-        )
-      },
-      search,
-      relative,
-    }
-  })()
-
-  const layout = (() => {
-    type PaneState = { size: number; visible: boolean }
-    type LayoutState = { panes: Record<string, PaneState>; order: string[] }
-    type PaneDefault = number | { size: number; visible?: boolean }
-
-    const [store, setStore] = createStore<Record<string, LayoutState>>({})
-
-    const raw = localStorage.getItem("layout")
-    if (raw) {
-      const data = JSON.parse(raw)
-      if (data && typeof data === "object" && !Array.isArray(data)) {
-        const first = Object.values(data)[0] as LayoutState
-        if (first && typeof first === "object" && "panes" in first) {
-          setStore(() => data as Record<string, LayoutState>)
+        setStore("active", relativePath)
+        context.addActive()
+        if (options?.pinned) setStore("node", path, "pinned", true)
+        if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
+        if (store.node[relativePath].loaded) return
+        return load(relativePath)
+      }
+
+      const list = async (path: string) => {
+        return sdk.client.file.list({ query: { path: path + "/" } }).then((x) => {
+          setStore(
+            "node",
+            produce((draft) => {
+              x.data!.forEach((node) => {
+                if (node.path in draft) return
+                draft[node.path] = node
+              })
+            }),
+          )
+        })
+      }
+
+      const search = (query: string) => sdk.client.find.files({ query: { query } }).then((x) => x.data!)
+
+      sdk.event.listen((e) => {
+        const event = e.details
+        switch (event.type) {
+          case "message.part.updated":
+            const part = event.properties.part
+            if (part.type === "tool" && part.state.status === "completed") {
+              switch (part.tool) {
+                case "read":
+                  break
+                case "edit":
+                  // load(part.state.input["filePath"] as string)
+                  break
+                default:
+                  break
+              }
+            }
+            break
+          case "file.watcher.updated":
+            setTimeout(sync.load.changes, 1000)
+            const relativePath = relative(event.properties.file)
+            if (relativePath.startsWith(".git/")) return
+            load(relativePath)
+            break
         }
         }
+      })
+
+      return {
+        active,
+        opened,
+        node: (path: string) => store.node[path],
+        update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
+        open,
+        load,
+        init,
+        close(path: string) {
+          setStore("opened", (opened) => opened.filter((x) => x !== path))
+          if (store.active === path) {
+            const index = store.opened.findIndex((f) => f === path)
+            const previous = store.opened[Math.max(0, index - 1)]
+            setStore("active", previous)
+          }
+          resetNode(path)
+        },
+        expand(path: string) {
+          setStore("node", path, "expanded", true)
+          if (store.node[path].loaded) return
+          setStore("node", path, "loaded", true)
+          list(path)
+        },
+        collapse(path: string) {
+          setStore("node", path, "expanded", false)
+        },
+        select(path: string, selection: TextSelection | undefined) {
+          setStore("node", path, "selection", selection)
+        },
+        scroll(path: string, scrollTop: number) {
+          setStore("node", path, "scrollTop", scrollTop)
+        },
+        move(path: string, to: number) {
+          const index = store.opened.findIndex((f) => f === path)
+          if (index === -1) return
+          setStore(
+            "opened",
+            produce((opened) => {
+              opened.splice(to, 0, opened.splice(index, 1)[0])
+            }),
+          )
+          setStore("node", path, "pinned", true)
+        },
+        view(path: string): View {
+          const n = store.node[path]
+          return n && n.view ? n.view : "raw"
+        },
+        setView(path: string, view: View) {
+          setStore("node", path, "view", view)
+        },
+        unfold(path: string, key: string) {
+          setStore("node", path, "folded", (xs) => {
+            const a = xs ?? []
+            if (a.includes(key)) return a
+            return [...a, key]
+          })
+        },
+        fold(path: string, key: string) {
+          setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key))
+        },
+        folded(path: string) {
+          const n = store.node[path]
+          return n && n.folded ? n.folded : []
+        },
+        changeIndex(path: string) {
+          return store.node[path]?.selectedChange
+        },
+        setChangeIndex(path: string, index: number | undefined) {
+          setStore("node", path, "selectedChange", index)
+        },
+        changes,
+        changed,
+        children(path: string) {
+          return Object.values(store.node).filter(
+            (x) =>
+              x.path.startsWith(path) &&
+              x.path !== path &&
+              !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"),
+          )
+        },
+        search,
+        relative,
       }
       }
-    }
+    })()
 
 
-    createEffect(() => {
-      localStorage.setItem("layout", JSON.stringify(store))
-    })
+    const session = (() => {
+      const [store, setStore] = createStore<{
+        active?: string
+        activeMessage?: string
+      }>({})
 
 
-    const normalize = (value: PaneDefault): PaneState => {
-      if (typeof value === "number") return { size: value, visible: true }
-      return { size: value.size, visible: value.visible ?? true }
-    }
+      const active = createMemo(() => {
+        if (!store.active) return undefined
+        return sync.session.get(store.active)
+      })
 
 
-    const ensure = (id: string, defaults: Record<string, PaneDefault>) => {
-      const entries = Object.entries(defaults)
-      if (!entries.length) return
-      setStore(id, (current) => {
-        if (current) return current
-        return {
-          panes: Object.fromEntries(entries.map(([pane, config]) => [pane, normalize(config)])),
-          order: entries.map(([pane]) => pane),
-        }
+      createEffect(() => {
+        if (!store.active) return
+        sync.session.sync(store.active)
       })
       })
-      for (const [pane, config] of entries) {
-        if (!store[id]?.panes[pane]) {
-          setStore(id, "panes", pane, () => normalize(config))
-        }
-        if (!(store[id]?.order ?? []).includes(pane)) {
-          setStore(id, "order", (list) => [...list, pane])
+
+      const valid = (part: Part) => {
+        if (!part) return false
+        switch (part.type) {
+          case "step-start":
+          case "step-finish":
+          case "file":
+          case "patch":
+            return false
+          case "text":
+            return !part.synthetic && part.text.trim()
+          case "reasoning":
+            return part.text.trim()
+          case "tool":
+            switch (part.tool) {
+              case "todoread":
+              case "todowrite":
+              case "list":
+              case "grep":
+                return false
+            }
+            return true
+          default:
+            return true
         }
         }
       }
       }
-    }
 
 
-    const ensurePane = (id: string, pane: string, fallback?: PaneDefault) => {
-      if (!store[id]) {
-        const value = normalize(fallback ?? { size: 0, visible: true })
-        setStore(id, () => ({
-          panes: { [pane]: value },
-          order: [pane],
-        }))
-        return
-      }
-      if (!store[id].panes[pane]) {
-        const value = normalize(fallback ?? { size: 0, visible: true })
-        setStore(id, "panes", pane, () => value)
+      const hasValidParts = (message: Message) => {
+        return sync.data.part[message.id]?.filter(valid).length > 0
       }
       }
-      if (!store[id].order.includes(pane)) {
-        setStore(id, "order", (list) => [...list, pane])
-      }
-    }
+      // const hasTextPart = (message: Message) => {
+      //   return !!sync.data.part[message.id]?.filter(valid).find((p) => p.type === "text")
+      // }
+
+      const messages = createMemo(() => (store.active ? (sync.data.message[store.active] ?? []) : []))
+      const messagesWithValidParts = createMemo(() => messages().filter(hasValidParts) ?? [])
+      const userMessages = createMemo(() =>
+        messages()
+          .filter((m) => m.role === "user")
+          .sort((a, b) => b.id.localeCompare(a.id)),
+      )
+
+      const working = createMemo(() => {
+        const last = messages()[messages().length - 1]
+        if (!last) return false
+        if (last.role === "user") return true
+        return !last.time.completed
+      })
 
 
-    const size = (id: string, pane: string) => store[id]?.panes[pane]?.size ?? 0
-    const visible = (id: string, pane: string) => store[id]?.panes[pane]?.visible ?? false
+      const cost = createMemo(() => {
+        const total = pipe(
+          messages(),
+          sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
+        )
+        return new Intl.NumberFormat("en-US", {
+          style: "currency",
+          currency: "USD",
+        }).format(total)
+      })
 
 
-    const setSize = (id: string, pane: string, value: number) => {
-      if (!store[id]?.panes[pane]) return
-      const next = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
-      setStore(id, "panes", pane, "size", next)
-    }
+      const last = createMemo(() => {
+        return messages().findLast((x) => x.role === "assistant") as AssistantMessage
+      })
 
 
-    const setVisible = (id: string, pane: string, value: boolean) => {
-      if (!store[id]?.panes[pane]) return
-      setStore(id, "panes", pane, "visible", value)
-    }
+      const lastUserMessage = createMemo(() => {
+        return userMessages()?.at(0)
+      })
 
 
-    const toggle = (id: string, pane: string) => {
-      setVisible(id, pane, !visible(id, pane))
-    }
+      const activeMessage = createMemo(() => {
+        if (!store.active || !store.activeMessage) return lastUserMessage()
+        return sync.data.message[store.active]?.find((m) => m.id === store.activeMessage)
+      })
 
 
-    const show = (id: string, pane: string) => setVisible(id, pane, true)
-    const hide = (id: string, pane: string) => setVisible(id, pane, false)
-    const order = (id: string) => store[id]?.order ?? []
-
-    return {
-      ensure,
-      ensurePane,
-      size,
-      visible,
-      setSize,
-      setVisible,
-      toggle,
-      show,
-      hide,
-      order,
-    }
-  })()
-
-  const session = (() => {
-    const [store, setStore] = createStore<{
-      active?: string
-    }>({})
-
-    const active = createMemo(() => {
-      if (!store.active) return undefined
-      return sync.session.get(store.active)
-    })
-
-    createEffect(() => {
-      if (!store.active) return
-      sync.session.sync(store.active)
-    })
-
-    return {
-      active,
-      setActive(sessionId: string | undefined) {
-        setStore("active", sessionId)
-      },
-      clearActive() {
-        setStore("active", undefined)
-      },
-    }
-  })()
-
-  const context = (() => {
-    const [store, setStore] = createStore<{
-      activeTab: boolean
-      files: string[]
-      activeFile?: string
-      items: (ContextItem & { key: string })[]
-    }>({
-      activeTab: true,
-      files: [],
-      items: [],
-    })
-    const files = createMemo(() => store.files.map((x) => file.node(x)))
-    const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
-
-    return {
-      all() {
-        return store.items
-      },
-      active() {
-        return store.activeTab ? file.active() : undefined
-      },
-      addActive() {
-        setStore("activeTab", true)
-      },
-      removeActive() {
-        setStore("activeTab", false)
-      },
-      add(item: ContextItem) {
-        let key = item.type
-        switch (item.type) {
-          case "file":
-            key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
-            break
-        }
-        if (store.items.find((x) => x.key === key)) return
-        setStore("items", (x) => [...x, { key, ...item }])
-      },
-      remove(key: string) {
-        setStore("items", (x) => x.filter((x) => x.key !== key))
-      },
-      files,
-      openFile(path: string) {
-        file.init(path).then(() => {
-          setStore("files", (x) => [...x, path])
-          setStore("activeFile", path)
-        })
-      },
-      activeFile,
-      setActiveFile(path: string | undefined) {
-        setStore("activeFile", path)
-      },
-    }
-  })()
-
-  const result = {
-    model,
-    agent,
-    file,
-    layout,
-    session,
-    context,
-  }
-  return result
-}
+      const activeAssistantMessages = createMemo(() => {
+        if (!store.active || !activeMessage()) return []
+        return sync.data.message[store.active]?.filter(
+          (m) => m.role === "assistant" && m.parentID == activeMessage()?.id,
+        )
+      })
 
 
-type LocalContext = ReturnType<typeof init>
+      const activeAssistantMessagesWithText = createMemo(() => {
+        if (!store.active || !activeAssistantMessages()) return []
+        return activeAssistantMessages()?.filter((m) => sync.data.part[m.id].find((p) => p.type === "text"))
+      })
 
 
-const ctx = createContext<LocalContext>()
+      const model = createMemo(() => {
+        if (!last()) return
+        const model = sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID]
+        return model
+      })
 
 
-export function LocalProvider(props: ParentProps) {
-  const value = init()
-  return <ctx.Provider value={value}>{props.children}</ctx.Provider>
-}
+      const tokens = createMemo(() => {
+        if (!last()) return
+        const tokens = last().tokens
+        const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+        return new Intl.NumberFormat("en-US", {
+          notation: "compact",
+          compactDisplay: "short",
+        }).format(total)
+      })
 
 
-export function useLocal() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useLocal must be used within a LocalProvider")
-  }
-  return value
-}
+      const context = createMemo(() => {
+        if (!last()) return
+        if (!model()?.limit.context) return 0
+        const tokens = last().tokens
+        const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+        return Math.round((total / model()!.limit.context) * 100)
+      })
+
+      const getMessageText = (message: Message | Message[] | undefined): string => {
+        if (!message) return ""
+        if (Array.isArray(message)) return message.map((m) => getMessageText(m)).join(" ")
+        return sync.data.part[message.id]
+          ?.filter((p) => p.type === "text")
+          ?.filter((p) => !p.synthetic)
+          .map((p) => p.text)
+          .join(" ")
+      }
+
+      return {
+        active,
+        activeMessage,
+        activeAssistantMessages,
+        activeAssistantMessagesWithText,
+        lastUserMessage,
+        cost,
+        last,
+        model,
+        tokens,
+        context,
+        messages,
+        messagesWithValidParts,
+        userMessages,
+        working,
+        getMessageText,
+        setActive(sessionId: string | undefined) {
+          setStore("active", sessionId)
+          setStore("activeMessage", undefined)
+        },
+        clearActive() {
+          setStore("active", undefined)
+          setStore("activeMessage", undefined)
+        },
+        setActiveMessage(messageId: string | undefined) {
+          setStore("activeMessage", messageId)
+        },
+        clearActiveMessage() {
+          setStore("activeMessage", undefined)
+        },
+      }
+    })()
+
+    const context = (() => {
+      const [store, setStore] = createStore<{
+        activeTab: boolean
+        files: string[]
+        activeFile?: string
+        items: (ContextItem & { key: string })[]
+      }>({
+        activeTab: true,
+        files: [],
+        items: [],
+      })
+      const files = createMemo(() => store.files.map((x) => file.node(x)))
+      const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined))
+
+      return {
+        all() {
+          return store.items
+        },
+        active() {
+          return store.activeTab ? file.active() : undefined
+        },
+        addActive() {
+          setStore("activeTab", true)
+        },
+        removeActive() {
+          setStore("activeTab", false)
+        },
+        add(item: ContextItem) {
+          let key = item.type
+          switch (item.type) {
+            case "file":
+              key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}`
+              break
+          }
+          if (store.items.find((x) => x.key === key)) return
+          setStore("items", (x) => [...x, { key, ...item }])
+        },
+        remove(key: string) {
+          setStore("items", (x) => x.filter((x) => x.key !== key))
+        },
+        files,
+        openFile(path: string) {
+          file.init(path).then(() => {
+            setStore("files", (x) => [...x, path])
+            setStore("activeFile", path)
+          })
+        },
+        activeFile,
+        setActiveFile(path: string | undefined) {
+          setStore("activeFile", path)
+        },
+      }
+    })()
+
+    const result = {
+      model,
+      agent,
+      file,
+      session,
+      context,
+    }
+    return result
+  },
+})

+ 25 - 38
packages/desktop/src/context/marked.tsx

@@ -1,43 +1,30 @@
-import { createContext, useContext, type ParentProps } from "solid-js"
-import { useShiki } from "@/context"
 import { marked } from "marked"
 import { marked } from "marked"
 import markedShiki from "marked-shiki"
 import markedShiki from "marked-shiki"
 import { bundledLanguages, type BundledLanguage } from "shiki"
 import { bundledLanguages, type BundledLanguage } from "shiki"
 
 
-function init(highlighter: ReturnType<typeof useShiki>) {
-  return marked.use(
-    markedShiki({
-      async highlight(code, lang) {
-        if (!(lang in bundledLanguages)) {
-          lang = "text"
-        }
-        if (!highlighter.getLoadedLanguages().includes(lang)) {
-          await highlighter.loadLanguage(lang as BundledLanguage)
-        }
-        return highlighter.codeToHtml(code, {
-          lang: lang || "text",
-          theme: "opencode",
-          tabindex: false,
-        })
-      },
-    }),
-  )
-}
+import { createSimpleContext } from "./helper"
+import { useShiki } from "./shiki"
 
 
-type MarkedContext = ReturnType<typeof init>
-
-const ctx = createContext<MarkedContext>()
-
-export function MarkedProvider(props: ParentProps) {
-  const highlighter = useShiki()
-  const value = init(highlighter)
-  return <ctx.Provider value={value}>{props.children}</ctx.Provider>
-}
-
-export function useMarked() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useMarked must be used within a MarkedProvider")
-  }
-  return value
-}
+export const { use: useMarked, provider: MarkedProvider } = createSimpleContext({
+  name: "Marked",
+  init: () => {
+    const highlighter = useShiki()
+    return marked.use(
+      markedShiki({
+        async highlight(code, lang) {
+          if (!(lang in bundledLanguages)) {
+            lang = "text"
+          }
+          if (!highlighter.getLoadedLanguages().includes(lang)) {
+            await highlighter.loadLanguage(lang as BundledLanguage)
+          }
+          return highlighter.codeToHtml(code, {
+            lang: lang || "text",
+            theme: "opencode",
+            tabindex: false,
+          })
+        },
+      }),
+    )
+  },
+})

+ 32 - 24
packages/desktop/src/context/sdk.tsx

@@ -1,29 +1,37 @@
-import { createContext, useContext, type ParentProps } from "solid-js"
-import { createOpencodeClient } from "@opencode-ai/sdk/client"
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
+import { createSimpleContext } from "./helper"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
 
 
-const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
-const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
+export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
+  name: "SDK",
+  init: (props: { url: string }) => {
+    const abort = new AbortController()
+    const sdk = createOpencodeClient({
+      baseUrl: props.url,
+      signal: abort.signal,
+      fetch: (req) => {
+        // @ts-ignore
+        req.timeout = false
+        return fetch(req)
+      },
+    })
 
 
-function init() {
-  const client = createOpencodeClient({
-    baseUrl: `http://${host}:${port}`,
-  })
-  return client
-}
+    const emitter = createGlobalEmitter<{
+      [key in Event["type"]]: Extract<Event, { type: key }>
+    }>()
 
 
-type SDKContext = ReturnType<typeof init>
+    sdk.event.subscribe().then(async (events) => {
+      for await (const event of events.stream) {
+        console.log("event", event.type)
+        emitter.emit(event.type, event)
+      }
+    })
 
 
-const ctx = createContext<SDKContext>()
+    onCleanup(() => {
+      abort.abort()
+    })
 
 
-export function SDKProvider(props: ParentProps) {
-  const value = init()
-  return <ctx.Provider value={value}>{props.children}</ctx.Provider>
-}
-
-export function useSDK() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useSDK must be used within a SDKProvider")
-  }
-  return value
-}
+    return { client: sdk, event: emitter }
+  },
+})

+ 7 - 17
packages/desktop/src/context/shiki.tsx

@@ -1,5 +1,5 @@
+import { createSimpleContext } from "./helper"
 import { createHighlighter, type ThemeInput } from "shiki"
 import { createHighlighter, type ThemeInput } from "shiki"
-import { createContext, useContext, type ParentProps } from "solid-js"
 
 
 const theme: ThemeInput = {
 const theme: ThemeInput = {
   colors: {
   colors: {
@@ -559,24 +559,14 @@ const theme: ThemeInput = {
   ],
   ],
   type: "dark",
   type: "dark",
 }
 }
-
 const highlighter = await createHighlighter({
 const highlighter = await createHighlighter({
   themes: [theme],
   themes: [theme],
   langs: [],
   langs: [],
 })
 })
 
 
-type ShikiContext = typeof highlighter
-
-const ctx = createContext<ShikiContext>()
-
-export function ShikiProvider(props: ParentProps) {
-  return <ctx.Provider value={highlighter}>{props.children}</ctx.Provider>
-}
-
-export function useShiki() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useShiki must be used within a ShikiProvider")
-  }
-  return value
-}
+export const { use: useShiki, provider: ShikiProvider } = createSimpleContext({
+  name: "Shiki",
+  init: () => {
+    return highlighter
+  },
+})

+ 143 - 158
packages/desktop/src/context/sync.tsx

@@ -1,177 +1,162 @@
 import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
 import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
-import { createContext, createMemo, Show, useContext, type ParentProps } from "solid-js"
-import { useSDK, useEvent } from "@/context"
+import { createMemo } from "solid-js"
 import { Binary } from "@/utils/binary"
 import { Binary } from "@/utils/binary"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
 
 
-function init() {
-  const [store, setStore] = createStore<{
-    ready: boolean
-    provider: Provider[]
-    agent: Agent[]
-    project: Project
-    config: Config
-    path: Path
-    session: Session[]
-    message: {
-      [sessionID: string]: Message[]
-    }
-    part: {
-      [messageID: string]: Part[]
-    }
-    node: FileNode[]
-    changes: File[]
-  }>({
-    project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
-    config: {},
-    path: { state: "", config: "", worktree: "", directory: "" },
-    ready: false,
-    agent: [],
-    provider: [],
-    session: [],
-    message: {},
-    part: {},
-    node: [],
-    changes: [],
-  })
-
-  const bus = useEvent()
-  bus.listen((event) => {
-    switch (event.type) {
-      case "session.updated": {
-        const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
-        if (result.found) {
-          setStore("session", result.index, reconcile(event.properties.info))
-          break
-        }
-        setStore(
-          "session",
-          produce((draft) => {
-            draft.splice(result.index, 0, event.properties.info)
-          }),
-        )
-        break
+export const { use: useSync, provider: SyncProvider } = createSimpleContext({
+  name: "Sync",
+  init: () => {
+    const [store, setStore] = createStore<{
+      ready: boolean
+      provider: Provider[]
+      agent: Agent[]
+      project: Project
+      config: Config
+      path: Path
+      session: Session[]
+      message: {
+        [sessionID: string]: Message[]
       }
       }
-      case "message.updated": {
-        const messages = store.message[event.properties.info.sessionID]
-        if (!messages) {
-          setStore("message", event.properties.info.sessionID, [event.properties.info])
-          break
-        }
-        const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
-        if (result.found) {
-          setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+      part: {
+        [messageID: string]: Part[]
+      }
+      node: FileNode[]
+      changes: File[]
+    }>({
+      project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
+      config: {},
+      path: { state: "", config: "", worktree: "", directory: "" },
+      ready: false,
+      agent: [],
+      provider: [],
+      session: [],
+      message: {},
+      part: {},
+      node: [],
+      changes: [],
+    })
+
+    const sdk = useSDK()
+    sdk.event.listen((e) => {
+      const event = e.details
+      switch (event.type) {
+        case "session.updated": {
+          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+          if (result.found) {
+            setStore("session", result.index, reconcile(event.properties.info))
+            break
+          }
+          setStore(
+            "session",
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.info)
+            }),
+          )
           break
           break
         }
         }
-        setStore(
-          "message",
-          event.properties.info.sessionID,
-          produce((draft) => {
-            draft.splice(result.index, 0, event.properties.info)
-          }),
-        )
-        break
-      }
-      case "message.part.updated": {
-        const parts = store.part[event.properties.part.messageID]
-        if (!parts) {
-          setStore("part", event.properties.part.messageID, [event.properties.part])
+        case "message.updated": {
+          const messages = store.message[event.properties.info.sessionID]
+          if (!messages) {
+            setStore("message", event.properties.info.sessionID, [event.properties.info])
+            break
+          }
+          const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
+          if (result.found) {
+            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+            break
+          }
+          setStore(
+            "message",
+            event.properties.info.sessionID,
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.info)
+            }),
+          )
           break
           break
         }
         }
-        const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
-        if (result.found) {
-          setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
+        case "message.part.updated": {
+          const parts = store.part[event.properties.part.messageID]
+          if (!parts) {
+            setStore("part", event.properties.part.messageID, [event.properties.part])
+            break
+          }
+          const result = Binary.search(parts, event.properties.part.id, (p) => p.id)
+          if (result.found) {
+            setStore("part", event.properties.part.messageID, result.index, reconcile(event.properties.part))
+            break
+          }
+          setStore(
+            "part",
+            event.properties.part.messageID,
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.part)
+            }),
+          )
           break
           break
         }
         }
-        setStore(
-          "part",
-          event.properties.part.messageID,
-          produce((draft) => {
-            draft.splice(result.index, 0, event.properties.part)
-          }),
-        )
-        break
       }
       }
-    }
-  })
-
-  const sdk = useSDK()
+    })
 
 
-  const load = {
-    project: () => sdk.project.current().then((x) => setStore("project", x.data!)),
-    provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
-    path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
-    agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
-    session: () =>
-      sdk.session.list().then((x) =>
-        setStore(
-          "session",
-          (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
+    const load = {
+      project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
+      provider: () => sdk.client.config.providers().then((x) => setStore("provider", x.data!.providers)),
+      path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)),
+      agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])),
+      session: () =>
+        sdk.client.session.list().then((x) =>
+          setStore(
+            "session",
+            (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
+          ),
         ),
         ),
-      ),
-    config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
-    changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
-    node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
-  }
+      config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
+      changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
+      node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
+    }
 
 
-  Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
+    Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
 
 
-  const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
-  const sanitize = (text: string) => text.replace(sanitizer(), "")
-  const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
+    const sanitizer = createMemo(() => new RegExp(`${store.path.directory}/`, "g"))
+    const sanitize = (text: string) => text.replace(sanitizer(), "")
+    const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
 
 
-  return {
-    data: store,
-    set: setStore,
-    session: {
-      get(sessionID: string) {
-        const match = Binary.search(store.session, sessionID, (s) => s.id)
-        if (match.found) return store.session[match.index]
-        return undefined
+    return {
+      data: store,
+      set: setStore,
+      get ready() {
+        return store.ready
       },
       },
-      async sync(sessionID: string) {
-        const [session, messages] = await Promise.all([
-          sdk.session.get({ path: { id: sessionID } }),
-          sdk.session.messages({ path: { id: sessionID } }),
-        ])
-        setStore(
-          produce((draft) => {
-            const match = Binary.search(draft.session, sessionID, (s) => s.id)
-            draft.session[match.index] = session.data!
-            draft.message[sessionID] = messages
-              .data!.map((x) => x.info)
-              .slice()
-              .sort((a, b) => a.id.localeCompare(b.id))
-            for (const message of messages.data!) {
-              draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
-            }
-          }),
-        )
+      session: {
+        get(sessionID: string) {
+          const match = Binary.search(store.session, sessionID, (s) => s.id)
+          if (match.found) return store.session[match.index]
+          return undefined
+        },
+        async sync(sessionID: string) {
+          const [session, messages] = await Promise.all([
+            sdk.client.session.get({ path: { id: sessionID } }),
+            sdk.client.session.messages({ path: { id: sessionID } }),
+          ])
+          setStore(
+            produce((draft) => {
+              const match = Binary.search(draft.session, sessionID, (s) => s.id)
+              draft.session[match.index] = session.data!
+              draft.message[sessionID] = messages
+                .data!.map((x) => x.info)
+                .slice()
+                .sort((a, b) => a.id.localeCompare(b.id))
+              for (const message of messages.data!) {
+                draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+              }
+            }),
+          )
+        },
       },
       },
-    },
-    load,
-    absolute,
-    sanitize,
-  }
-}
-
-type SyncContext = ReturnType<typeof init>
-
-const ctx = createContext<SyncContext>()
-
-export function SyncProvider(props: ParentProps) {
-  const value = init()
-  return (
-    <Show when={value.data.ready}>
-      <ctx.Provider value={value}>{props.children}</ctx.Provider>
-    </Show>
-  )
-}
-
-export function useSync() {
-  const value = useContext(ctx)
-  if (!value) {
-    throw new Error("useSync must be used within a SyncProvider")
-  }
-  return value
-}
+      load,
+      absolute,
+      sanitize,
+    }
+  },
+})

+ 19 - 14
packages/desktop/src/index.tsx

@@ -3,10 +3,17 @@ import "@/index.css"
 import { render } from "solid-js/web"
 import { render } from "solid-js/web"
 import { Router, Route } from "@solidjs/router"
 import { Router, Route } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { MetaProvider } from "@solidjs/meta"
-import { EventProvider, SDKProvider, SyncProvider, LocalProvider, ShikiProvider, MarkedProvider } from "@/context"
 import { Fonts } from "@opencode-ai/ui"
 import { Fonts } from "@opencode-ai/ui"
+import { ShikiProvider } from "./context/shiki"
+import { MarkedProvider } from "./context/marked"
+import { SDKProvider } from "./context/sdk"
+import { SyncProvider } from "./context/sync"
+import { LocalProvider } from "./context/local"
 import Home from "@/pages"
 import Home from "@/pages"
 
 
+const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
+const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
+
 const root = document.getElementById("root")
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
   throw new Error(
   throw new Error(
@@ -18,19 +25,17 @@ render(
   () => (
   () => (
     <ShikiProvider>
     <ShikiProvider>
       <MarkedProvider>
       <MarkedProvider>
-        <SDKProvider>
-          <EventProvider>
-            <SyncProvider>
-              <LocalProvider>
-                <MetaProvider>
-                  <Fonts />
-                  <Router>
-                    <Route path="/" component={Home} />
-                  </Router>
-                </MetaProvider>
-              </LocalProvider>
-            </SyncProvider>
-          </EventProvider>
+        <SDKProvider url={`http://${host}:${port}`}>
+          <SyncProvider>
+            <LocalProvider>
+              <MetaProvider>
+                <Fonts />
+                <Router>
+                  <Route path="/" component={Home} />
+                </Router>
+              </MetaProvider>
+            </LocalProvider>
+          </SyncProvider>
         </SDKProvider>
         </SDKProvider>
       </MarkedProvider>
       </MarkedProvider>
     </ShikiProvider>
     </ShikiProvider>

+ 349 - 23
packages/desktop/src/pages/index.tsx

@@ -1,15 +1,26 @@
-import { Button, Icon, List, SelectDialog, Tooltip } from "@opencode-ai/ui"
+import { Button, List, SelectDialog, Tooltip, IconButton, Tabs } from "@opencode-ai/ui"
 import { FileIcon } from "@/ui"
 import { FileIcon } from "@/ui"
 import FileTree from "@/components/file-tree"
 import FileTree from "@/components/file-tree"
-import EditorPane from "@/components/editor-pane"
-import { For, onCleanup, onMount, Show } from "solid-js"
-import { useSync, useSDK, useLocal } from "@/context"
-import type { LocalFile, TextSelection } from "@/context/local"
-import SessionTimeline from "@/components/session-timeline"
+import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect } from "solid-js"
+import { useLocal, type LocalFile, type TextSelection } from "@/context/local"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { getDirectory, getFilename } from "@/utils"
 import { getDirectory, getFilename } from "@/utils"
 import { ContentPart, PromptInput } from "@/components/prompt-input"
 import { ContentPart, PromptInput } from "@/components/prompt-input"
 import { DateTime } from "luxon"
 import { DateTime } from "luxon"
+import {
+  DragDropProvider,
+  DragDropSensors,
+  DragOverlay,
+  SortableProvider,
+  closestCenter,
+  createSortable,
+  useDragDropContext,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
+import type { JSX } from "solid-js"
+import { Code } from "@/components/code"
+import { useSync } from "@/context/sync"
+import { useSDK } from "@/context/sdk"
 
 
 export default function Page() {
 export default function Page() {
   const local = useLocal()
   const local = useLocal()
@@ -17,10 +28,18 @@ export default function Page() {
   const sdk = useSDK()
   const sdk = useSDK()
   const [store, setStore] = createStore({
   const [store, setStore] = createStore({
     clickTimer: undefined as number | undefined,
     clickTimer: undefined as number | undefined,
-    modelSelectOpen: false,
     fileSelectOpen: false,
     fileSelectOpen: false,
   })
   })
   let inputRef!: HTMLDivElement
   let inputRef!: HTMLDivElement
+  let messageScrollElement!: HTMLDivElement
+  const [activeItem, setActiveItem] = createSignal<string | undefined>(undefined)
+
+  createEffect(() => {
+    if (!local.session.activeMessage()) return
+    if (!messageScrollElement) return
+    const element = messageScrollElement.querySelector(`[data-message="${local.session.activeMessage()?.id}"]`)
+    element?.scrollIntoView({ block: "start", behavior: "instant" })
+  })
 
 
   const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
   const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
 
 
@@ -101,11 +120,50 @@ export default function Page() {
     }
     }
   }
   }
 
 
+  const navigateChange = (dir: 1 | -1) => {
+    const active = local.file.active()
+    if (!active) return
+    const current = local.file.changeIndex(active.path)
+    const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
+    local.file.setChangeIndex(active.path, next)
+  }
+
+  const handleTabChange = (path: string) => {
+    if (path === "chat" || path === "review") return
+    local.file.open(path)
+  }
+
+  const handleTabClose = (file: LocalFile) => {
+    local.file.close(file.path)
+  }
+
+  const handleDragStart = (event: unknown) => {
+    const id = getDraggableId(event)
+    if (!id) return
+    setActiveItem(id)
+  }
+
+  const handleDragOver = (event: DragEvent) => {
+    const { draggable, droppable } = event
+    if (draggable && droppable) {
+      const currentFiles = local.file.opened().map((file) => file.path)
+      const fromIndex = currentFiles.indexOf(draggable.id.toString())
+      const toIndex = currentFiles.indexOf(droppable.id.toString())
+      if (fromIndex !== toIndex) {
+        local.file.move(draggable.id.toString(), toIndex)
+      }
+    }
+  }
+
+  const handleDragEnd = () => {
+    setActiveItem(undefined)
+  }
+
   const handlePromptSubmit = async (parts: ContentPart[]) => {
   const handlePromptSubmit = async (parts: ContentPart[]) => {
     const existingSession = local.session.active()
     const existingSession = local.session.active()
     let session = existingSession
     let session = existingSession
     if (!session) {
     if (!session) {
-      const created = await sdk.session.create()
+      const created = await sdk.client.session.create()
       session = created.data ?? undefined
       session = created.data ?? undefined
     }
     }
     if (!session) return
     if (!session) return
@@ -187,7 +245,7 @@ export default function Page() {
       }
       }
     })
     })
 
 
-    await sdk.session.prompt({
+    await sdk.client.session.prompt({
       path: { id: session.id },
       path: { id: session.id },
       body: {
       body: {
         agent: local.agent.current()!.name,
         agent: local.agent.current()!.name,
@@ -211,6 +269,93 @@ export default function Page() {
     inputRef?.focus()
     inputRef?.focus()
   }
   }
 
 
+  const TabVisual = (props: { file: LocalFile }): JSX.Element => {
+    return (
+      <div class="flex items-center gap-x-1.5">
+        <FileIcon node={props.file} class="_grayscale-100" />
+        <span
+          classList={{
+            "text-14-medium": true,
+            "text-primary": !!props.file.status?.status,
+            italic: !props.file.pinned,
+          }}
+        >
+          {props.file.name}
+        </span>
+        <span class="hidden opacity-70">
+          <Switch>
+            <Match when={props.file.status?.status === "modified"}>
+              <span class="text-primary">M</span>
+            </Match>
+            <Match when={props.file.status?.status === "added"}>
+              <span class="text-success">A</span>
+            </Match>
+            <Match when={props.file.status?.status === "deleted"}>
+              <span class="text-error">D</span>
+            </Match>
+          </Switch>
+        </span>
+      </div>
+    )
+  }
+
+  const SortableTab = (props: {
+    file: LocalFile
+    onTabClick: (file: LocalFile) => void
+    onTabClose: (file: LocalFile) => void
+  }): JSX.Element => {
+    const sortable = createSortable(props.file.path)
+
+    return (
+      // @ts-ignore
+      <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
+        <Tooltip value={props.file.path} placement="bottom" class="h-full">
+          <div class="relative h-full">
+            <Tabs.Trigger value={props.file.path} class="peer/tab pr-7" onClick={() => props.onTabClick(props.file)}>
+              <TabVisual file={props.file} />
+            </Tabs.Trigger>
+            <IconButton
+              icon="close"
+              class="absolute right-1 top-1.5 opacity-0 text-text-muted/60 peer-data-[selected]/tab:opacity-100 peer-data-[selected]/tab:text-text peer-data-[selected]/tab:hover:bg-border-subtle hover:opacity-100 peer-hover/tab:opacity-100"
+              variant="ghost"
+              onClick={() => props.onTabClose(props.file)}
+            />
+          </div>
+        </Tooltip>
+      </div>
+    )
+  }
+
+  const ConstrainDragYAxis = (): JSX.Element => {
+    const context = useDragDropContext()
+    if (!context) return <></>
+    const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+    const transformer: Transformer = {
+      id: "constrain-y-axis",
+      order: 100,
+      callback: (transform) => ({ ...transform, y: 0 }),
+    }
+    onDragStart((event) => {
+      const id = getDraggableId(event)
+      if (!id) return
+      addTransformer("draggables", id, transformer)
+    })
+    onDragEnd((event) => {
+      const id = getDraggableId(event)
+      if (!id) return
+      removeTransformer("draggables", id, transformer.id)
+    })
+    return <></>
+  }
+
+  const getDraggableId = (event: unknown): string | undefined => {
+    if (typeof event !== "object" || event === null) return undefined
+    if (!("draggable" in event)) return undefined
+    const draggable = (event as { draggable?: { id?: unknown } }).draggable
+    if (!draggable) return undefined
+    return typeof draggable.id === "string" ? draggable.id : undefined
+  }
+
   return (
   return (
     <div class="relative h-screen flex flex-col">
     <div class="relative h-screen flex flex-col">
       <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
       <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
@@ -253,22 +398,203 @@ export default function Page() {
             </List>
             </List>
           </div>
           </div>
         </div>
         </div>
-        <div class="relative grid grid-cols-2 bg-background-base w-full">
-          <div class="pt-1.5 min-w-0 overflow-y-auto no-scrollbar flex justify-center">
-            <Show when={local.session.active()}>
-              {(activeSession) => <SessionTimeline session={activeSession().id} class="w-full" />}
-            </Show>
-          </div>
-          <div class="p-1.5 pl-px flex flex-col items-center justify-center overflow-y-auto no-scrollbar">
-            <Show when={local.session.active()}>
-              <EditorPane onFileClick={handleFileClick} />
-            </Show>
-          </div>
+        <div class="relative bg-background-base w-full h-full overflow-x-hidden">
+          <DragDropProvider
+            onDragStart={handleDragStart}
+            onDragEnd={handleDragEnd}
+            onDragOver={handleDragOver}
+            collisionDetector={closestCenter}
+          >
+            <DragDropSensors />
+            <ConstrainDragYAxis />
+            <Tabs onChange={handleTabChange}>
+              <div class="sticky top-0 shrink-0 flex">
+                <Tabs.List>
+                  <Tabs.Trigger value="chat" class="flex gap-x-1.5 items-center">
+                    <div>Chat</div>
+                    <Show when={local.session.active()}>
+                      <div class="flex flex-col h-4 px-2 -mr-2 justify-center items-center rounded-full bg-surface-base text-12-medium text-text-strong">
+                        {local.session.context()}%
+                      </div>
+                    </Show>
+                  </Tabs.Trigger>
+                  {/* <Tabs.Trigger value="review">Review</Tabs.Trigger> */}
+                  <SortableProvider ids={local.file.opened().map((file) => file.path)}>
+                    <For each={local.file.opened()}>
+                      {(file) => <SortableTab file={file} onTabClick={handleFileClick} onTabClose={handleTabClose} />}
+                    </For>
+                  </SortableProvider>
+                  <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+                    <IconButton
+                      icon="plus-small"
+                      variant="ghost"
+                      iconSize="large"
+                      onClick={() => setStore("fileSelectOpen", true)}
+                    />
+                  </div>
+                </Tabs.List>
+                <div class="hidden shrink-0 h-full _flex items-center gap-1 px-2 border-b border-border-subtle/40">
+                  <Show when={local.file.active() && local.file.active()!.content?.diff}>
+                    {(() => {
+                      const activeFile = local.file.active()!
+                      const view = local.file.view(activeFile.path)
+                      return (
+                        <div class="flex items-center gap-1">
+                          <Show when={view !== "raw"}>
+                            <div class="mr-1 flex items-center gap-1">
+                              <Tooltip value="Previous change" placement="bottom">
+                                <IconButton icon="arrow-up" variant="ghost" onClick={() => navigateChange(-1)} />
+                              </Tooltip>
+                              <Tooltip value="Next change" placement="bottom">
+                                <IconButton icon="arrow-down" variant="ghost" onClick={() => navigateChange(1)} />
+                              </Tooltip>
+                            </div>
+                          </Show>
+                          <Tooltip value="Raw" placement="bottom">
+                            <IconButton
+                              icon="file-text"
+                              variant="ghost"
+                              classList={{
+                                "text-text": view === "raw",
+                                "text-text-muted/70": view !== "raw",
+                                "bg-background-element": view === "raw",
+                              }}
+                              onClick={() => local.file.setView(activeFile.path, "raw")}
+                            />
+                          </Tooltip>
+                          <Tooltip value="Unified diff" placement="bottom">
+                            <IconButton
+                              icon="checklist"
+                              variant="ghost"
+                              classList={{
+                                "text-text": view === "diff-unified",
+                                "text-text-muted/70": view !== "diff-unified",
+                                "bg-background-element": view === "diff-unified",
+                              }}
+                              onClick={() => local.file.setView(activeFile.path, "diff-unified")}
+                            />
+                          </Tooltip>
+                          <Tooltip value="Split diff" placement="bottom">
+                            <IconButton
+                              icon="columns"
+                              variant="ghost"
+                              classList={{
+                                "text-text": view === "diff-split",
+                                "text-text-muted/70": view !== "diff-split",
+                                "bg-background-element": view === "diff-split",
+                              }}
+                              onClick={() => local.file.setView(activeFile.path, "diff-split")}
+                            />
+                          </Tooltip>
+                        </div>
+                      )
+                    })()}
+                  </Show>
+                </div>
+              </div>
+              <Tabs.Content value="chat" class="select-text flex flex-col flex-1 min-h-0">
+                <Show when={local.session.active()} fallback={<div>No active session</div>}>
+                  {(activeSession) => (
+                    <div class="p-6 pt-12 max-w-[904px] mx-auto flex flex-col flex-1 min-h-0">
+                      <div class="py-3 flex flex-col flex-1 min-h-0">
+                        <div class="flex items-start gap-8 flex-1 min-h-0">
+                          <ul role="list" class="w-60 shrink-0 flex flex-col items-start gap-1">
+                            <For each={local.session.userMessages()}>
+                              {(message) => (
+                                <li
+                                  class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
+                                  onClick={() => local.session.setActiveMessage(message.id)}
+                                >
+                                  <div class="w-[18px] shrink-0">
+                                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 12" fill="none">
+                                      <g>
+                                        <rect x="0" width="2" height="12" rx="1" fill="#CFCECD" />
+                                        <rect x="4" width="2" height="12" rx="1" fill="#CFCECD" />
+                                        <rect x="8" width="2" height="12" rx="1" fill="#CFCECD" />
+                                        <rect x="12" width="2" height="12" rx="1" fill="#CFCECD" />
+                                        <rect x="16" width="2" height="12" rx="1" fill="#CFCECD" />
+                                      </g>
+                                    </svg>
+                                  </div>
+                                  <div
+                                    data-active={local.session.activeMessage()?.id === message.id}
+                                    classList={{
+                                      "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+                                      "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+                                    }}
+                                  >
+                                    {local.session.getMessageText(message)}
+                                  </div>
+                                </li>
+                              )}
+                            </For>
+                          </ul>
+                          <div
+                            ref={messageScrollElement}
+                            class="grow min-w-0 h-full overflow-y-auto no-scrollbar snap-y"
+                          >
+                            <div class="flex flex-col items-start gap-50 pb-[800px]">
+                              <For each={local.session.userMessages()}>
+                                {(message) => (
+                                  <div
+                                    data-message={message.id}
+                                    class="flex flex-col items-start self-stretch gap-8 pt-1.5 snap-start"
+                                  >
+                                    <div class="flex flex-col items-start gap-4">
+                                      <div class="text-14-medium text-text-strong overflow-hidden text-ellipsis min-w-0">
+                                        {local.session.getMessageText(message)}
+                                      </div>
+                                      <div class="text-14-regular text-text-base">
+                                        {message.summary?.text ||
+                                          local.session.getMessageText(local.session.activeAssistantMessagesWithText())}
+                                      </div>
+                                    </div>
+                                    <div class=""></div>
+                                  </div>
+                                )}
+                              </For>
+                            </div>
+                          </div>
+                        </div>
+                      </div>
+                    </div>
+                  )}
+                </Show>
+              </Tabs.Content>
+              {/* <Tabs.Content value="review" class="select-text"></Tabs.Content> */}
+              <For each={local.file.opened()}>
+                {(file) => (
+                  <Tabs.Content value={file.path} class="select-text">
+                    {(() => {
+                      const view = local.file.view(file.path)
+                      const showRaw = view === "raw" || !file.content?.diff
+                      const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "")
+                      return <Code path={file.path} code={code} class="[&_code]:pb-60" />
+                    })()}
+                  </Tabs.Content>
+                )}
+              </For>
+            </Tabs>
+            <DragOverlay>
+              {(() => {
+                const id = activeItem()
+                if (!id) return null
+                const draggedFile = local.file.node(id)
+                if (!draggedFile) return null
+                return (
+                  <div class="relative px-3 h-8 flex items-center text-sm font-medium text-text whitespace-nowrap shrink-0 bg-background-panel border-x border-border-subtle/40 border-b border-b-transparent">
+                    <TabVisual file={draggedFile} />
+                  </div>
+                )
+              })()}
+            </DragOverlay>
+          </DragDropProvider>
           <div
           <div
             classList={{
             classList={{
-              "absolute inset-x-0 px-8 flex flex-col justify-center items-center z-50": true,
-              "bottom-8": !!local.session.active(),
-              "bottom-1/2 translate-y-1/2": !local.session.active(),
+              "absolute inset-x-0 px-6 max-w-[904px] flex flex-col justify-center items-center z-50 mx-auto": true,
+              "bottom-8": true,
+              // "bottom-8": !!local.session.active(),
+              // "bottom-1/2 translate-y-1/2": !local.session.active(),
             }}
             }}
           >
           >
             <PromptInput
             <PromptInput

+ 3 - 2
packages/ui/src/components/icon-button.tsx

@@ -5,11 +5,12 @@ import { Icon, IconProps } from "./icon"
 export interface IconButtonProps {
 export interface IconButtonProps {
   icon: IconProps["name"]
   icon: IconProps["name"]
   size?: "normal" | "large"
   size?: "normal" | "large"
+  iconSize?: IconProps["size"]
   variant?: "primary" | "secondary" | "ghost"
   variant?: "primary" | "secondary" | "ghost"
 }
 }
 
 
 export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
 export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
-  const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
+  const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList"])
   return (
   return (
     <Kobalte
     <Kobalte
       {...rest}
       {...rest}
@@ -21,7 +22,7 @@ export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
         [split.class ?? ""]: !!split.class,
         [split.class ?? ""]: !!split.class,
       }}
       }}
     >
     >
-      <Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
+      <Icon data-slot="icon" name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />
     </Kobalte>
     </Kobalte>
   )
   )
 }
 }

+ 2 - 2
packages/ui/src/components/icon.css

@@ -18,8 +18,8 @@
   }
   }
 
 
   &[data-size="large"] {
   &[data-size="large"] {
-    width: 32px;
-    height: 32px;
+    width: 24px;
+    height: 24px;
   }
   }
 
 
   [data-slot="svg"] {
   [data-slot="svg"] {

+ 13 - 11
packages/ui/src/components/tabs.css

@@ -3,14 +3,11 @@
   height: 100%;
   height: 100%;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
-  border-width: 1px;
-  border-style: solid;
-  border-radius: var(--radius-sm);
-  border-color: var(--border-weak-base);
   background-color: var(--background-stronger);
   background-color: var(--background-stronger);
   overflow: clip;
   overflow: clip;
 
 
   [data-slot="list"] {
   [data-slot="list"] {
+    height: 40px;
     width: 100%;
     width: 100%;
     position: relative;
     position: relative;
     display: flex;
     display: flex;
@@ -32,7 +29,6 @@
       height: 100%;
       height: 100%;
       border-bottom: 1px solid var(--border-weak-base);
       border-bottom: 1px solid var(--border-weak-base);
       background-color: var(--background-base);
       background-color: var(--background-base);
-      border-top-right-radius: var(--radius-sm);
     }
     }
 
 
     &:empty::after {
     &:empty::after {
@@ -42,19 +38,25 @@
 
 
   [data-slot="trigger"] {
   [data-slot="trigger"] {
     position: relative;
     position: relative;
-    height: 36px;
-    padding: 8px 12px;
+    height: 100%;
+    padding: 8px 24px;
     display: flex;
     display: flex;
     align-items: center;
     align-items: center;
-    font-size: var(--text-sm);
+    color: var(--text-base);
+
+    /* text-14-medium */
+    font-family: var(--font-family-sans);
+    font-size: 14px;
+    font-style: normal;
     font-weight: var(--font-weight-medium);
     font-weight: var(--font-weight-medium);
-    color: var(--text-weak);
+    line-height: var(--line-height-large); /* 142.857% */
+    letter-spacing: var(--letter-spacing-normal);
 
 
     white-space: nowrap;
     white-space: nowrap;
     flex-shrink: 0;
     flex-shrink: 0;
     border-bottom: 1px solid var(--border-weak-base);
     border-bottom: 1px solid var(--border-weak-base);
     border-right: 1px solid var(--border-weak-base);
     border-right: 1px solid var(--border-weak-base);
-    background-color: var(--background-weak);
+    background-color: var(--background-base);
     transition:
     transition:
       background-color 0.15s ease,
       background-color 0.15s ease,
       color 0.15s ease;
       color 0.15s ease;
@@ -68,7 +70,7 @@
       box-shadow: 0 0 0 2px var(--border-focus);
       box-shadow: 0 0 0 2px var(--border-focus);
     }
     }
     &[data-selected] {
     &[data-selected] {
-      color: var(--text-base);
+      color: var(--text-strong);
       background-color: transparent;
       background-color: transparent;
       border-bottom-color: transparent;
       border-bottom-color: transparent;
     }
     }