Adam преди 2 месеца
родител
ревизия
d66d806700

+ 7 - 4
packages/desktop/src/app.tsx

@@ -9,7 +9,8 @@ import { Diff } from "@opencode-ai/ui/diff"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { LayoutProvider } from "@/context/layout"
 import { GlobalSDKProvider } from "@/context/global-sdk"
-import { SessionProvider } from "@/context/session"
+import { TerminalProvider } from "@/context/terminal"
+import { PromptProvider } from "@/context/prompt"
 import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { CommandProvider } from "@/context/command"
@@ -53,9 +54,11 @@ export function App() {
                             path="/session/:id?"
                             component={(p) => (
                               <Show when={p.params.id || true} keyed>
-                                <SessionProvider>
-                                  <Session />
-                                </SessionProvider>
+                                <TerminalProvider>
+                                  <PromptProvider>
+                                    <Session />
+                                  </PromptProvider>
+                                </TerminalProvider>
                               </Show>
                             )}
                           />

+ 8 - 3
packages/desktop/src/components/dialog-select-file.tsx

@@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useSession } from "@/context/session"
+import { useLayout } from "@/context/layout"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
 
 export function DialogSelectFile() {
-  const session = useSession()
+  const layout = useLayout()
   const local = useLocal()
   const dialog = useDialog()
+  const params = useParams()
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
   return (
     <Dialog title="Select file">
       <List
@@ -20,7 +25,7 @@ export function DialogSelectFile() {
         key={(x) => x}
         onSelect={(path) => {
           if (path) {
-            session.layout.openTab("file://" + path)
+            tabs().open("file://" + path)
           }
           dialog.clear()
         }}

+ 47 - 31
packages/desktop/src/components/prompt-input.tsx

@@ -4,9 +4,10 @@ import { createStore } from "solid-js/store"
 import { makePersisted } from "@solid-primitives/storage"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt } from "@/context/prompt"
+import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
-import { useNavigate } from "@solidjs/router"
+import { useNavigate, useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Button } from "@opencode-ai/ui/button"
@@ -67,12 +68,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
   const sync = useSync()
   const local = useLocal()
-  const session = useSession()
+  const prompt = usePrompt()
+  const layout = useLayout()
+  const params = useParams()
   const dialog = useDialog()
   const providers = useProviders()
   const command = useCommand()
   let editorRef!: HTMLDivElement
 
+  // Session-derived state
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
+  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const status = createMemo(
+    () =>
+      sync.data.session_status[params.id ?? ""] ?? {
+        type: "idle",
+      },
+  )
+  const working = createMemo(() => status()?.type !== "idle")
+
   const [store, setStore] = createStore<{
     popover: "file" | "slash" | null
     historyIndex: number
@@ -111,9 +126,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
 
-  const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
-    const length = position === "start" ? 0 : promptLength(prompt)
-    session.prompt.set(prompt, length)
+  const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+    const length = position === "start" ? 0 : promptLength(p)
+    prompt.set(p, length)
     requestAnimationFrame(() => {
       editorRef.focus()
       setCursorPosition(editorRef, length)
@@ -149,9 +164,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   createEffect(() => {
-    session.id
+    params.id
     editorRef.focus()
-    if (session.id) return
+    if (params.id) return
     const interval = setInterval(() => {
       setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
     }, 6500)
@@ -211,7 +226,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!cmd) return
     // Since slash commands only trigger from start, just clear the input
     editorRef.innerHTML = ""
-    session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
     setStore("popover", null)
     command.trigger(cmd.id, "slash")
   }
@@ -243,7 +258,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   createEffect(
     on(
-      () => session.prompt.current(),
+      () => prompt.current(),
       (currentParts) => {
         const domParts = parseFromDOM()
         if (isPromptEqual(currentParts, domParts)) return
@@ -255,7 +270,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }
 
         editorRef.innerHTML = ""
-        currentParts.forEach((part) => {
+        currentParts.forEach((part: ContentPart) => {
           if (part.type === "text") {
             editorRef.appendChild(document.createTextNode(part.content))
           } else if (part.type === "file") {
@@ -333,7 +348,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       setStore("savedPrompt", null)
     }
 
-    session.prompt.set(rawParts, cursorPosition)
+    prompt.set(rawParts, cursorPosition)
   }
 
   const addPart = (part: ContentPart) => {
@@ -341,8 +356,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!selection || selection.rangeCount === 0) return
 
     const cursorPosition = getCursorPosition(editorRef)
-    const prompt = session.prompt.current()
-    const rawText = prompt.map((p) => p.content).join("")
+    const currentPrompt = prompt.current()
+    const rawText = currentPrompt.map((p: ContentPart) => p.content).join("")
     const textBeforeCursor = rawText.substring(0, cursorPosition)
     const atMatch = textBeforeCursor.match(/@(\S*)$/)
 
@@ -403,7 +418,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const abort = () =>
     sdk.client.session.abort({
-      sessionID: session.id!,
+      sessionID: params.id!,
     })
 
   const addToHistory = (prompt: Prompt) => {
@@ -430,7 +445,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (direction === "up") {
       if (entries.length === 0) return false
       if (current === -1) {
-        setStore("savedPrompt", clonePromptParts(session.prompt.current()))
+        setStore("savedPrompt", clonePromptParts(prompt.current()))
         setStore("historyIndex", 0)
         applyHistoryPrompt(entries[0], "start")
         return true
@@ -481,7 +496,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
       if (!collapsed) return
       const cursorPos = getCursorPosition(editorRef)
-      const textLength = promptLength(session.prompt.current())
+      const textLength = promptLength(prompt.current())
       const inHistory = store.historyIndex >= 0
       const isStart = cursorPos === 0
       const isEnd = cursorPos === textLength
@@ -511,7 +526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (event.key === "Escape") {
       if (store.popover) {
         setStore("popover", null)
-      } else if (session.working()) {
+      } else if (working()) {
         abort()
       }
     }
@@ -519,18 +534,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
-    const prompt = session.prompt.current()
-    const text = prompt.map((part) => part.content).join("")
+    const currentPrompt = prompt.current()
+    const text = currentPrompt.map((part: ContentPart) => part.content).join("")
     if (text.trim().length === 0) {
-      if (session.working()) abort()
+      if (working()) abort()
       return
     }
 
-    addToHistory(prompt)
+    addToHistory(currentPrompt)
     setStore("historyIndex", -1)
     setStore("savedPrompt", null)
 
-    let existing = session.info()
+    let existing = info()
     if (!existing) {
       const created = await sdk.client.session.create()
       existing = created.data ?? undefined
@@ -539,7 +554,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!existing) return
 
     const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
-    const attachments = prompt.filter((part) => part.type === "file")
+    const attachments = currentPrompt.filter(
+      (part: ContentPart) => part.type === "file",
+    ) as import("@/context/prompt").FileAttachmentPart[]
 
     const attachmentParts = attachments.map((attachment) => {
       const absolute = toAbsolutePath(attachment.path)
@@ -563,10 +580,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       }
     })
 
-    session.layout.setActiveTab(undefined)
-    session.messages.setActive(undefined)
+    tabs().setActive(undefined)
     editorRef.innerHTML = ""
-    session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
 
     sdk.client.session.prompt({
       sessionID: existing.id,
@@ -671,7 +687,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               "[&>[data-type=file]]:text-icon-info-active": true,
             }}
           />
-          <Show when={!session.prompt.dirty()}>
+          <Show when={!prompt.dirty()}>
             <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
               Ask anything... "{PLACEHOLDERS[store.placeholder]}"
             </div>
@@ -703,7 +719,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             inactive={!session.prompt.dirty() && !session.working()}
             value={
               <Switch>
-                <Match when={session.working()}>
+                <Match when={working()}>
                   <div class="flex items-center gap-2">
                     <span>Stop</span>
                     <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
@@ -720,8 +736,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           >
             <IconButton
               type="submit"
-              disabled={!session.prompt.dirty() && !session.working()}
-              icon={session.working() ? "stop" : "arrow-up"}
+              disabled={!prompt.dirty() && !working()}
+              icon={working() ? "stop" : "arrow-up"}
               variant="primary"
               class="h-10 w-8 absolute right-2 bottom-2"
             />

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

@@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
 import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
 import { useSDK } from "@/context/sdk"
 import { SerializeAddon } from "@/addons/serialize"
-import { LocalPTY } from "@/context/session"
+import { LocalPTY } from "@/context/terminal"
 import { usePrefersDark } from "@solid-primitives/media"
 
 export interface TerminalProps extends ComponentProps<"div"> {

+ 1 - 1
packages/desktop/src/context/command.tsx

@@ -138,7 +138,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
         search={{ placeholder: "Search commands", autofocus: true }}
         emptyMessage="No commands found"
         items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
-        key={(x) => x.id}
+        key={(x) => x?.id}
         groupBy={(x) => x.category ?? ""}
         onSelect={(option) => {
           if (option) {

+ 89 - 3
packages/desktop/src/context/layout.tsx

@@ -1,5 +1,5 @@
-import { createStore } from "solid-js/store"
-import { createMemo, onMount } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { batch, createMemo, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
@@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) {
   }
 }
 
+type SessionTabs = {
+  active?: string
+  all: string[]
+}
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
@@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         review: {
           state: "pane" as "pane" | "tab",
         },
+        sessionTabs: {} as Record<string, SessionTabs>,
       }),
       {
-        name: "layout.v2",
+        name: "layout.v3",
       },
     )
 
@@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("review", "state", "tab")
         },
       },
+      tabs(sessionKey: string) {
+        const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
+        return {
+          tabs,
+          active: createMemo(() => tabs().active),
+          all: createMemo(() => tabs().all),
+          setActive(tab: string | undefined) {
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all: [], active: tab })
+            } else {
+              setStore("sessionTabs", sessionKey, "active", tab)
+            }
+          },
+          setAll(all: string[]) {
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all, active: undefined })
+            } else {
+              setStore("sessionTabs", sessionKey, "all", all)
+            }
+          },
+          async open(tab: string) {
+            if (tab === "chat") {
+              if (!store.sessionTabs[sessionKey]) {
+                setStore("sessionTabs", sessionKey, { all: [], active: undefined })
+              } else {
+                setStore("sessionTabs", sessionKey, "active", undefined)
+              }
+              return
+            }
+            const current = store.sessionTabs[sessionKey] ?? { all: [] }
+            if (tab !== "review") {
+              if (!current.all.includes(tab)) {
+                if (!store.sessionTabs[sessionKey]) {
+                  setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
+                } else {
+                  setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
+                  setStore("sessionTabs", sessionKey, "active", tab)
+                }
+                return
+              }
+            }
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all: [], active: tab })
+            } else {
+              setStore("sessionTabs", sessionKey, "active", tab)
+            }
+          },
+          close(tab: string) {
+            const current = store.sessionTabs[sessionKey]
+            if (!current) return
+            batch(() => {
+              setStore(
+                "sessionTabs",
+                sessionKey,
+                "all",
+                current.all.filter((x) => x !== tab),
+              )
+              if (current.active === tab) {
+                const index = current.all.findIndex((f) => f === tab)
+                const previous = current.all[Math.max(0, index - 1)]
+                setStore("sessionTabs", sessionKey, "active", previous)
+              }
+            })
+          },
+          move(tab: string, to: number) {
+            const current = store.sessionTabs[sessionKey]
+            if (!current) return
+            const index = current.all.findIndex((f) => f === tab)
+            if (index === -1) return
+            setStore(
+              "sessionTabs",
+              sessionKey,
+              "all",
+              produce((opened) => {
+                opened.splice(to, 0, opened.splice(index, 1)[0])
+              }),
+            )
+          },
+        }
+      },
     }
   },
 })

+ 100 - 0
packages/desktop/src/context/prompt.tsx

@@ -0,0 +1,100 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { useParams } from "@solidjs/router"
+import { TextSelection } from "./local"
+
+interface PartBase {
+  content: string
+  start: number
+  end: number
+}
+
+export interface TextPart extends PartBase {
+  type: "text"
+}
+
+export interface FileAttachmentPart extends PartBase {
+  type: "file"
+  path: string
+  selection?: TextSelection
+}
+
+export type ContentPart = TextPart | FileAttachmentPart
+export type Prompt = ContentPart[]
+
+export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+  if (promptA.length !== promptB.length) return false
+  for (let i = 0; i < promptA.length; i++) {
+    const partA = promptA[i]
+    const partB = promptB[i]
+    if (partA.type !== partB.type) return false
+    if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
+      return false
+    }
+    if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
+      return false
+    }
+  }
+  return true
+}
+
+function cloneSelection(selection?: TextSelection) {
+  if (!selection) return undefined
+  return { ...selection }
+}
+
+function clonePart(part: ContentPart): ContentPart {
+  if (part.type === "text") return { ...part }
+  return {
+    ...part,
+    selection: cloneSelection(part.selection),
+  }
+}
+
+function clonePrompt(prompt: Prompt): Prompt {
+  return prompt.map(clonePart)
+}
+
+export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
+  name: "Prompt",
+  init: () => {
+    const params = useParams()
+    const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
+
+    const [store, setStore] = makePersisted(
+      createStore<{
+        prompt: Prompt
+        cursor?: number
+      }>({
+        prompt: clonePrompt(DEFAULT_PROMPT),
+        cursor: undefined,
+      }),
+      {
+        name: name(),
+      },
+    )
+
+    return {
+      current: createMemo(() => store.prompt),
+      cursor: createMemo(() => store.cursor),
+      dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+      set(prompt: Prompt, cursorPosition?: number) {
+        const next = clonePrompt(prompt)
+        batch(() => {
+          setStore("prompt", next)
+          if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+        })
+      },
+      reset() {
+        batch(() => {
+          setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+          setStore("cursor", 0)
+        })
+      },
+    }
+  },
+})

+ 0 - 321
packages/desktop/src/context/session.tsx

@@ -1,321 +0,0 @@
-import { createStore, produce } from "solid-js/store"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo } from "solid-js"
-import { useSync } from "./sync"
-import { makePersisted } from "@solid-primitives/storage"
-import { TextSelection } from "./local"
-import { pipe, sumBy } from "remeda"
-import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
-import { useParams } from "@solidjs/router"
-import { useSDK } from "./sdk"
-
-export type LocalPTY = {
-  id: string
-  title: string
-  rows?: number
-  cols?: number
-  buffer?: string
-  scrollY?: number
-}
-
-export const { use: useSession, provider: SessionProvider } = createSimpleContext({
-  name: "Session",
-  init: () => {
-    const sdk = useSDK()
-    const params = useParams()
-    const sync = useSync()
-    const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`)
-
-    const [store, setStore] = makePersisted(
-      createStore<{
-        messageId?: string
-        tabs: {
-          active?: string
-          all: string[]
-        }
-        prompt: Prompt
-        cursor?: number
-        terminals: {
-          active?: string
-          all: LocalPTY[]
-        }
-      }>({
-        tabs: {
-          all: [],
-        },
-        prompt: clonePrompt(DEFAULT_PROMPT),
-        cursor: undefined,
-        terminals: { all: [] },
-      }),
-      {
-        name: name(),
-      },
-    )
-
-    createEffect(() => {
-      if (!params.id) return
-      sync.session.sync(params.id)
-    })
-
-    const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-    const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
-    const userMessages = createMemo(() =>
-      messages()
-        .filter((m) => m.role === "user")
-        .sort((a, b) => a.id.localeCompare(b.id)),
-    )
-    const lastUserMessage = createMemo(() => {
-      return userMessages()?.at(-1)
-    })
-    const activeMessage = createMemo(() => {
-      if (!store.messageId) return lastUserMessage()
-      return userMessages()?.find((m) => m.id === store.messageId)
-    })
-    const status = createMemo(
-      () =>
-        sync.data.session_status[params.id ?? ""] ?? {
-          type: "idle",
-        },
-    )
-    const working = createMemo(() => status()?.type !== "idle")
-
-    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 last = createMemo(
-      () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
-    )
-    const model = createMemo(() =>
-      last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
-    )
-    const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-
-    const tokens = createMemo(() => {
-      if (!last()) return
-      const tokens = last().tokens
-      return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
-    })
-
-    const context = createMemo(() => {
-      const total = tokens()
-      const limit = model()?.limit.context
-      if (!total || !limit) return 0
-      return Math.round((total / limit) * 100)
-    })
-
-    return {
-      get id() {
-        return params.id
-      },
-      info,
-      status,
-      working,
-      diffs,
-      prompt: {
-        current: createMemo(() => store.prompt),
-        cursor: createMemo(() => store.cursor),
-        dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
-        set(prompt: Prompt, cursorPosition?: number) {
-          const next = clonePrompt(prompt)
-          batch(() => {
-            setStore("prompt", next)
-            if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
-          })
-        },
-      },
-      messages: {
-        all: messages,
-        user: userMessages,
-        last: lastUserMessage,
-        active: activeMessage,
-        setActive(message: UserMessage | undefined) {
-          setStore("messageId", message?.id)
-        },
-      },
-      usage: {
-        tokens,
-        cost,
-        context,
-      },
-      layout: {
-        tabs: store.tabs,
-        setActiveTab(tab: string | undefined) {
-          setStore("tabs", "active", tab)
-        },
-        setOpenedTabs(tabs: string[]) {
-          setStore("tabs", "all", tabs)
-        },
-        async openTab(tab: string) {
-          if (tab === "chat") {
-            setStore("tabs", "active", undefined)
-            return
-          }
-          if (tab !== "review") {
-            if (!store.tabs.all.includes(tab)) {
-              setStore("tabs", "all", [...store.tabs.all, tab])
-            }
-          }
-          setStore("tabs", "active", tab)
-        },
-        closeTab(tab: string) {
-          batch(() => {
-            setStore(
-              "tabs",
-              "all",
-              store.tabs.all.filter((x) => x !== tab),
-            )
-            if (store.tabs.active === tab) {
-              const index = store.tabs.all.findIndex((f) => f === tab)
-              const previous = store.tabs.all[Math.max(0, index - 1)]
-              setStore("tabs", "active", previous)
-            }
-          })
-        },
-        moveTab(tab: string, to: number) {
-          const index = store.tabs.all.findIndex((f) => f === tab)
-          if (index === -1) return
-          setStore(
-            "tabs",
-            "all",
-            produce((opened) => {
-              opened.splice(to, 0, opened.splice(index, 1)[0])
-            }),
-          )
-        },
-      },
-      terminal: {
-        all: createMemo(() => Object.values(store.terminals.all)),
-        active: createMemo(() => store.terminals.active),
-        new() {
-          sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => {
-            const id = pty.data?.id
-            if (!id) return
-            setStore("terminals", "all", [
-              ...store.terminals.all,
-              {
-                id,
-                title: pty.data?.title ?? "Terminal",
-              },
-            ])
-            setStore("terminals", "active", id)
-          })
-        },
-        update(pty: Partial<LocalPTY> & { id: string }) {
-          setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
-          sdk.client.pty.update({
-            ptyID: pty.id,
-            title: pty.title,
-            size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-          })
-        },
-        async clone(id: string) {
-          const index = store.terminals.all.findIndex((x) => x.id === id)
-          const pty = store.terminals.all[index]
-          if (!pty) return
-          const clone = await sdk.client.pty.create({
-            title: pty.title,
-          })
-          if (!clone.data) return
-          setStore("terminals", "all", index, {
-            ...pty,
-            ...clone.data,
-          })
-          if (store.terminals.active === pty.id) {
-            setStore("terminals", "active", clone.data.id)
-          }
-        },
-        open(id: string) {
-          setStore("terminals", "active", id)
-        },
-        async close(id: string) {
-          batch(() => {
-            setStore(
-              "terminals",
-              "all",
-              store.terminals.all.filter((x) => x.id !== id),
-            )
-            if (store.terminals.active === id) {
-              const index = store.terminals.all.findIndex((f) => f.id === id)
-              const previous = store.tabs.all[Math.max(0, index - 1)]
-              setStore("terminals", "active", previous)
-            }
-          })
-          await sdk.client.pty.remove({ ptyID: id })
-        },
-        move(id: string, to: number) {
-          const index = store.terminals.all.findIndex((f) => f.id === id)
-          if (index === -1) return
-          setStore(
-            "terminals",
-            "all",
-            produce((all) => {
-              all.splice(to, 0, all.splice(index, 1)[0])
-            }),
-          )
-        },
-      },
-    }
-  },
-})
-
-interface PartBase {
-  content: string
-  start: number
-  end: number
-}
-
-export interface TextPart extends PartBase {
-  type: "text"
-}
-
-export interface FileAttachmentPart extends PartBase {
-  type: "file"
-  path: string
-  selection?: TextSelection
-}
-
-export type ContentPart = TextPart | FileAttachmentPart
-export type Prompt = ContentPart[]
-
-export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
-
-export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
-  if (promptA.length !== promptB.length) return false
-  for (let i = 0; i < promptA.length; i++) {
-    const partA = promptA[i]
-    const partB = promptB[i]
-    if (partA.type !== partB.type) return false
-    if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
-      return false
-    }
-    if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
-      return false
-    }
-  }
-  return true
-}
-
-function cloneSelection(selection?: TextSelection) {
-  if (!selection) return undefined
-  return { ...selection }
-}
-
-function clonePart(part: ContentPart): ContentPart {
-  if (part.type === "text") return { ...part }
-  return {
-    ...part,
-    selection: cloneSelection(part.selection),
-  }
-}
-
-function clonePrompt(prompt: Prompt): Prompt {
-  return prompt.map(clonePart)
-}

+ 106 - 0
packages/desktop/src/context/terminal.tsx

@@ -0,0 +1,106 @@
+import { createStore, produce } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { useParams } from "@solidjs/router"
+import { useSDK } from "./sdk"
+
+export type LocalPTY = {
+  id: string
+  title: string
+  rows?: number
+  cols?: number
+  buffer?: string
+  scrollY?: number
+}
+
+export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
+  name: "Terminal",
+  init: () => {
+    const sdk = useSDK()
+    const params = useParams()
+    const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
+
+    const [store, setStore] = makePersisted(
+      createStore<{
+        active?: string
+        all: LocalPTY[]
+      }>({
+        all: [],
+      }),
+      {
+        name: name(),
+      },
+    )
+
+    return {
+      all: createMemo(() => Object.values(store.all)),
+      active: createMemo(() => store.active),
+      new() {
+        sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
+          const id = pty.data?.id
+          if (!id) return
+          setStore("all", [
+            ...store.all,
+            {
+              id,
+              title: pty.data?.title ?? "Terminal",
+            },
+          ])
+          setStore("active", id)
+        })
+      },
+      update(pty: Partial<LocalPTY> & { id: string }) {
+        setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+        sdk.client.pty.update({
+          ptyID: pty.id,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+      },
+      async clone(id: string) {
+        const index = store.all.findIndex((x) => x.id === id)
+        const pty = store.all[index]
+        if (!pty) return
+        const clone = await sdk.client.pty.create({
+          title: pty.title,
+        })
+        if (!clone.data) return
+        setStore("all", index, {
+          ...pty,
+          ...clone.data,
+        })
+        if (store.active === pty.id) {
+          setStore("active", clone.data.id)
+        }
+      },
+      open(id: string) {
+        setStore("active", id)
+      },
+      async close(id: string) {
+        batch(() => {
+          setStore(
+            "all",
+            store.all.filter((x) => x.id !== id),
+          )
+          if (store.active === id) {
+            const index = store.all.findIndex((f) => f.id === id)
+            const previous = store.all[Math.max(0, index - 1)]
+            setStore("active", previous?.id)
+          }
+        })
+        await sdk.client.pty.remove({ ptyID: id })
+      },
+      move(id: string, to: number) {
+        const index = store.all.findIndex((f) => f.id === id)
+        if (index === -1) return
+        setStore(
+          "all",
+          produce((all) => {
+            all.splice(to, 0, all.splice(index, 1)[0])
+          }),
+        )
+      },
+    }
+  },
+})

+ 124 - 60
packages/desktop/src/pages/session.tsx

@@ -27,22 +27,91 @@ import {
 import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
 import type { JSX } from "solid-js"
 import { useSync } from "@/context/sync"
-import { useSession, type LocalPTY } from "@/context/session"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { useLayout } from "@/context/layout"
+import { usePrompt } from "@/context/prompt"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { Terminal } from "@/components/terminal"
 import { checksum } from "@opencode-ai/util/encode"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectFile } from "@/components/dialog-select-file"
 import { useCommand } from "@/context/command"
+import { useParams } from "@solidjs/router"
+import { pipe, sumBy } from "remeda"
+import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
 
 export default function Page() {
   const layout = useLayout()
   const local = useLocal()
   const sync = useSync()
-  const session = useSession()
+  const terminal = useTerminal()
+  const prompt = usePrompt()
   const dialog = useDialog()
   const command = useCommand()
+  const params = useParams()
+
+  // Session-specific derived state
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
+
+  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+  const userMessages = createMemo(() =>
+    messages()
+      .filter((m) => m.role === "user")
+      .sort((a, b) => a.id.localeCompare(b.id)),
+  )
+  const lastUserMessage = createMemo(() => userMessages()?.at(-1))
+
+  const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
+  const activeMessage = createMemo(() => {
+    if (!messageStore.messageId) return lastUserMessage()
+    return userMessages()?.find((m) => m.id === messageStore.messageId)
+  })
+  const setActiveMessage = (message: UserMessage | undefined) => {
+    setMessageStore("messageId", message?.id)
+  }
+
+  const status = createMemo(
+    () =>
+      sync.data.session_status[params.id ?? ""] ?? {
+        type: "idle",
+      },
+  )
+  const working = createMemo(() => status()?.type !== "idle")
+
+  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 last = createMemo(
+    () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
+  )
+  const model = createMemo(() =>
+    last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
+  )
+  const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+
+  const tokens = createMemo(() => {
+    if (!last()) return
+    const t = last().tokens
+    return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
+  })
+
+  const context = createMemo(() => {
+    const total = tokens()
+    const limit = model()?.limit.context
+    if (!total || !limit) return 0
+    return Math.round((total / limit) * 100)
+  })
+
   const [store, setStore] = createStore({
     clickTimer: undefined as number | undefined,
     activeDraggable: undefined as string | undefined,
@@ -50,10 +119,15 @@ export default function Page() {
   })
   let inputRef!: HTMLDivElement
 
+  createEffect(() => {
+    if (!params.id) return
+    sync.session.sync(params.id)
+  })
+
   createEffect(() => {
     if (layout.terminal.opened()) {
-      if (session.terminal.all().length === 0) {
-        session.terminal.new()
+      if (terminal.all().length === 0) {
+        terminal.new()
       }
     }
   })
@@ -99,7 +173,7 @@ export default function Page() {
       description: "Create a new terminal tab",
       category: "Terminal",
       keybind: "ctrl+shift+`",
-      onSelect: () => session.terminal.new(),
+      onSelect: () => terminal.new(),
     },
   ])
 
@@ -166,11 +240,11 @@ export default function Page() {
   const handleDragOver = (event: DragEvent) => {
     const { draggable, droppable } = event
     if (draggable && droppable) {
-      const currentTabs = session.layout.tabs.all
+      const currentTabs = tabs().all()
       const fromIndex = currentTabs?.indexOf(draggable.id.toString())
       const toIndex = currentTabs?.indexOf(droppable.id.toString())
       if (fromIndex !== toIndex && toIndex !== undefined) {
-        session.layout.moveTab(draggable.id.toString(), toIndex)
+        tabs().move(draggable.id.toString(), toIndex)
       }
     }
   }
@@ -188,11 +262,11 @@ export default function Page() {
   const handleTerminalDragOver = (event: DragEvent) => {
     const { draggable, droppable } = event
     if (draggable && droppable) {
-      const terminals = session.terminal.all()
-      const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
-      const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
+      const terminals = terminal.all()
+      const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
+      const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
       if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
-        session.terminal.move(draggable.id.toString(), toIndex)
+        terminal.move(draggable.id.toString(), toIndex)
       }
     }
   }
@@ -210,8 +284,8 @@ export default function Page() {
           <Tabs.Trigger
             value={props.terminal.id}
             closeButton={
-              session.terminal.all().length > 1 && (
-                <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} />
+              terminal.all().length > 1 && (
+                <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
               )
             }
           >
@@ -326,7 +400,7 @@ export default function Page() {
     return typeof draggable.id === "string" ? draggable.id : undefined
   }
 
-  const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
+  const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
 
   return (
     <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
@@ -339,7 +413,7 @@ export default function Page() {
         >
           <DragDropSensors />
           <ConstrainDragYAxis />
-          <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
+          <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
             <div class="sticky top-0 shrink-0 flex">
               <Tabs.List>
                 <Tabs.Trigger value="chat">
@@ -349,15 +423,15 @@ export default function Page() {
                       value={`${new Intl.NumberFormat("en-US", {
                         notation: "compact",
                         compactDisplay: "short",
-                      }).format(session.usage.tokens() ?? 0)} Tokens`}
+                      }).format(tokens() ?? 0)} Tokens`}
                       class="flex items-center gap-1.5"
                     >
-                      <ProgressCircle percentage={session.usage.context() ?? 0} />
-                      <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
+                      <ProgressCircle percentage={context() ?? 0} />
+                      <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
                     </Tooltip>
                   </div>
                 </Tabs.Trigger>
-                <Show when={layout.review.state() === "tab" && session.diffs().length}>
+                <Show when={layout.review.state() === "tab" && diffs().length}>
                   <Tabs.Trigger
                     value="review"
                     closeButton={
@@ -367,25 +441,23 @@ export default function Page() {
                     }
                   >
                     <div class="flex items-center gap-3">
-                      <Show when={session.diffs()}>
-                        <DiffChanges changes={session.diffs()} variant="bars" />
+                      <Show when={diffs()}>
+                        <DiffChanges changes={diffs()} variant="bars" />
                       </Show>
                       <div class="flex items-center gap-1.5">
                         <div>Review</div>
-                        <Show when={session.info()?.summary?.files}>
+                        <Show when={info()?.summary?.files}>
                           <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
-                            {session.info()?.summary?.files ?? 0}
+                            {info()?.summary?.files ?? 0}
                           </div>
                         </Show>
                       </div>
                     </div>
                   </Tabs.Trigger>
                 </Show>
-                <SortableProvider ids={session.layout.tabs.all ?? []}>
-                  <For each={session.layout.tabs.all ?? []}>
-                    {(tab) => (
-                      <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
-                    )}
+                <SortableProvider ids={tabs().all() ?? []}>
+                  <For each={tabs().all() ?? []}>
+                    {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
                   </For>
                 </SortableProvider>
                 <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
@@ -415,27 +487,23 @@ export default function Page() {
                   }}
                 >
                   <Switch>
-                    <Match when={session.id}>
+                    <Match when={params.id}>
                       <div class="flex items-start justify-start h-full min-h-0">
                         <SessionMessageRail
-                          messages={session.messages.user()}
-                          current={session.messages.active()}
-                          onMessageSelect={session.messages.setActive}
+                          messages={userMessages()}
+                          current={activeMessage()}
+                          onMessageSelect={setActiveMessage}
                           wide={wide()}
                         />
                         <SessionTurn
-                          sessionID={session.id!}
-                          messageID={session.messages.active()?.id!}
+                          sessionID={params.id!}
+                          messageID={activeMessage()?.id!}
                           classes={{
                             root: "pb-20 flex-1 min-w-0",
                             content: "pb-20",
                             container:
                               "w-full " +
-                              (wide()
-                                ? "max-w-146 mx-auto px-6"
-                                : session.messages.user().length > 1
-                                  ? "pr-6 pl-18"
-                                  : "px-6"),
+                              (wide() ? "max-w-146 mx-auto px-6" : userMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
                           }}
                         />
                       </div>
@@ -476,7 +544,7 @@ export default function Page() {
                     </div>
                   </div>
                 </div>
-                <Show when={layout.review.state() === "pane" && session.diffs().length}>
+                <Show when={layout.review.state() === "pane" && diffs().length}>
                   <div
                     classList={{
                       "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
@@ -488,7 +556,7 @@ export default function Page() {
                         header: "px-6",
                         container: "px-6",
                       }}
-                      diffs={session.diffs()}
+                      diffs={diffs()}
                       actions={
                         <Tooltip value="Open in tab">
                           <IconButton
@@ -496,7 +564,7 @@ export default function Page() {
                             variant="ghost"
                             onClick={() => {
                               layout.review.tab()
-                              session.layout.setActiveTab("review")
+                              tabs().setActive("review")
                             }}
                           />
                         </Tooltip>
@@ -506,7 +574,7 @@ export default function Page() {
                 </Show>
               </div>
             </Tabs.Content>
-            <Show when={layout.review.state() === "tab" && session.diffs().length}>
+            <Show when={layout.review.state() === "tab" && diffs().length}>
               <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
                 <div
                   classList={{
@@ -519,13 +587,13 @@ export default function Page() {
                       header: "px-6",
                       container: "px-6",
                     }}
-                    diffs={session.diffs()}
+                    diffs={diffs()}
                     split
                   />
                 </div>
               </Tabs.Content>
             </Show>
-            <For each={session.layout.tabs.all}>
+            <For each={tabs().all()}>
               {(tab) => {
                 const [file] = createResource(
                   () => tab,
@@ -579,7 +647,7 @@ export default function Page() {
             </Show>
           </DragOverlay>
         </DragDropProvider>
-        <Show when={session.layout.tabs.active}>
+        <Show when={tabs().active()}>
           <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
             <PromptInput
               ref={(el) => {
@@ -639,25 +707,21 @@ export default function Page() {
           >
             <DragDropSensors />
             <ConstrainDragYAxis />
-            <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
+            <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
               <Tabs.List class="h-10">
-                <SortableProvider ids={session.terminal.all().map((t) => t.id)}>
-                  <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
+                <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
+                  <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
                 </SortableProvider>
                 <div class="h-full flex items-center justify-center">
                   <Tooltip value="New Terminal" class="flex items-center">
-                    <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
+                    <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
                   </Tooltip>
                 </div>
               </Tabs.List>
-              <For each={session.terminal.all()}>
-                {(terminal) => (
-                  <Tabs.Content value={terminal.id}>
-                    <Terminal
-                      pty={terminal}
-                      onCleanup={session.terminal.update}
-                      onConnectError={() => session.terminal.clone(terminal.id)}
-                    />
+              <For each={terminal.all()}>
+                {(pty) => (
+                  <Tabs.Content value={pty.id}>
+                    <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
                   </Tabs.Content>
                 )}
               </For>
@@ -665,9 +729,9 @@ export default function Page() {
             <DragOverlay>
               <Show when={store.activeTerminalDraggable}>
                 {(draggedId) => {
-                  const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId()))
+                  const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
                   return (
-                    <Show when={terminal()}>
+                    <Show when={pty()}>
                       {(t) => (
                         <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                           {t().title}