Procházet zdrojové kódy

chore: refactoring and tests, splitting up files (#12495)

Adam před 1 týdnem
rodič
revize
2c58dd6203
100 změnil soubory, kde provedl 8003 přidání a 4695 odebrání
  1. 2 1
      packages/app/package.json
  2. 1 1
      packages/app/src/addons/serialize.test.ts
  3. 37 6
      packages/app/src/addons/serialize.ts
  4. 41 41
      packages/app/src/components/dialog-custom-provider.tsx
  5. 6 8
      packages/app/src/components/dialog-select-model.tsx
  6. 20 78
      packages/app/src/components/dialog-select-server.tsx
  7. 56 258
      packages/app/src/components/prompt-input.tsx
  8. 67 0
      packages/app/src/components/prompt-input/build-request-parts.test.ts
  9. 174 0
      packages/app/src/components/prompt-input/build-request-parts.ts
  10. 82 0
      packages/app/src/components/prompt-input/context-items.tsx
  11. 20 0
      packages/app/src/components/prompt-input/drag-overlay.tsx
  12. 51 0
      packages/app/src/components/prompt-input/image-attachments.tsx
  13. 35 0
      packages/app/src/components/prompt-input/placeholder.test.ts
  14. 13 0
      packages/app/src/components/prompt-input/placeholder.ts
  15. 144 0
      packages/app/src/components/prompt-input/slash-popover.tsx
  16. 23 199
      packages/app/src/components/prompt-input/submit.ts
  17. 77 0
      packages/app/src/components/server/server-row.tsx
  18. 39 88
      packages/app/src/components/status-popover.tsx
  19. 10 13
      packages/app/src/components/terminal.tsx
  20. 43 0
      packages/app/src/context/command-keybind.test.ts
  21. 10 30
      packages/app/src/context/file-content-eviction-accounting.test.ts
  22. 114 602
      packages/app/src/context/file.tsx
  23. 88 0
      packages/app/src/context/file/content-cache.ts
  24. 27 0
      packages/app/src/context/file/path.test.ts
  25. 119 0
      packages/app/src/context/file/path.ts
  26. 170 0
      packages/app/src/context/file/tree-store.ts
  27. 41 0
      packages/app/src/context/file/types.ts
  28. 136 0
      packages/app/src/context/file/view-cache.ts
  29. 118 0
      packages/app/src/context/file/watcher.test.ts
  30. 52 0
      packages/app/src/context/file/watcher.ts
  31. 120 1100
      packages/app/src/context/global-sync.tsx
  32. 195 0
      packages/app/src/context/global-sync/bootstrap.ts
  33. 263 0
      packages/app/src/context/global-sync/child-store.ts
  34. 201 0
      packages/app/src/context/global-sync/event-reducer.test.ts
  35. 319 0
      packages/app/src/context/global-sync/event-reducer.ts
  36. 28 0
      packages/app/src/context/global-sync/eviction.ts
  37. 83 0
      packages/app/src/context/global-sync/queue.ts
  38. 26 0
      packages/app/src/context/global-sync/session-load.ts
  39. 59 0
      packages/app/src/context/global-sync/session-trim.test.ts
  40. 56 0
      packages/app/src/context/global-sync/session-trim.ts
  41. 134 0
      packages/app/src/context/global-sync/types.ts
  42. 25 0
      packages/app/src/context/global-sync/utils.ts
  43. 20 0
      packages/app/src/context/language.tsx
  44. 23 60
      packages/app/src/context/layout-scroll.test.ts
  45. 3 13
      packages/app/src/context/server.tsx
  46. 56 0
      packages/app/src/context/sync-optimistic.test.ts
  47. 66 9
      packages/app/src/context/sync.tsx
  48. 2 2
      packages/app/src/i18n/ar.ts
  49. 2 2
      packages/app/src/i18n/br.ts
  50. 2 2
      packages/app/src/i18n/da.ts
  51. 41 2
      packages/app/src/i18n/de.ts
  52. 38 0
      packages/app/src/i18n/en.ts
  53. 2 2
      packages/app/src/i18n/es.ts
  54. 2 2
      packages/app/src/i18n/fr.ts
  55. 2 2
      packages/app/src/i18n/ja.ts
  56. 2 2
      packages/app/src/i18n/ko.ts
  57. 2 2
      packages/app/src/i18n/no.ts
  58. 31 0
      packages/app/src/i18n/parity.test.ts
  59. 2 2
      packages/app/src/i18n/pl.ts
  60. 2 2
      packages/app/src/i18n/ru.ts
  61. 2 2
      packages/app/src/i18n/th.ts
  62. 40 2
      packages/app/src/i18n/zh.ts
  63. 40 2
      packages/app/src/i18n/zht.ts
  64. 1 1
      packages/app/src/pages/directory-layout.tsx
  65. 51 1033
      packages/app/src/pages/layout.tsx
  66. 26 0
      packages/app/src/pages/layout/deep-links.ts
  67. 63 0
      packages/app/src/pages/layout/helpers.test.ts
  68. 65 0
      packages/app/src/pages/layout/helpers.ts
  69. 113 0
      packages/app/src/pages/layout/inline-editor.tsx
  70. 330 0
      packages/app/src/pages/layout/sidebar-items.tsx
  71. 63 0
      packages/app/src/pages/layout/sidebar-project-helpers.test.ts
  72. 11 0
      packages/app/src/pages/layout/sidebar-project-helpers.ts
  73. 283 0
      packages/app/src/pages/layout/sidebar-project.tsx
  74. 1 0
      packages/app/src/pages/layout/sidebar-shell-helpers.ts
  75. 13 0
      packages/app/src/pages/layout/sidebar-shell.test.ts
  76. 109 0
      packages/app/src/pages/layout/sidebar-shell.tsx
  77. 2 0
      packages/app/src/pages/layout/sidebar-workspace-helpers.ts
  78. 13 0
      packages/app/src/pages/layout/sidebar-workspace.test.ts
  79. 387 0
      packages/app/src/pages/layout/sidebar-workspace.tsx
  80. 157 1126
      packages/app/src/pages/session.tsx
  81. 40 0
      packages/app/src/pages/session/file-tab-scroll.test.ts
  82. 67 0
      packages/app/src/pages/session/file-tab-scroll.ts
  83. 516 0
      packages/app/src/pages/session/file-tabs.tsx
  84. 62 0
      packages/app/src/pages/session/message-gesture.test.ts
  85. 21 0
      packages/app/src/pages/session/message-gesture.ts
  86. 348 0
      packages/app/src/pages/session/message-timeline.tsx
  87. 158 0
      packages/app/src/pages/session/review-tab.tsx
  88. 10 0
      packages/app/src/pages/session/session-command-helpers.ts
  89. 36 0
      packages/app/src/pages/session/session-mobile-tabs.tsx
  90. 22 0
      packages/app/src/pages/session/session-prompt-dock.test.ts
  91. 137 0
      packages/app/src/pages/session/session-prompt-dock.tsx
  92. 4 0
      packages/app/src/pages/session/session-prompt-helpers.ts
  93. 306 0
      packages/app/src/pages/session/session-side-panel.tsx
  94. 16 0
      packages/app/src/pages/session/terminal-label.ts
  95. 25 0
      packages/app/src/pages/session/terminal-panel.test.ts
  96. 169 0
      packages/app/src/pages/session/terminal-panel.tsx
  97. 44 0
      packages/app/src/pages/session/use-session-commands.test.ts
  98. 439 0
      packages/app/src/pages/session/use-session-commands.tsx
  99. 16 0
      packages/app/src/pages/session/use-session-hash-scroll.test.ts
  100. 174 0
      packages/app/src/pages/session/use-session-hash-scroll.ts

+ 2 - 1
packages/app/package.json

@@ -15,7 +15,8 @@
     "build": "vite build",
     "serve": "vite preview",
     "test": "bun run test:unit",
-    "test:unit": "bun test ./src",
+    "test:unit": "bun test --preload ./happydom.ts ./src",
+    "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
     "test:e2e": "playwright test",
     "test:e2e:local": "bun script/e2e-local.ts",
     "test:e2e:ui": "playwright test --ui",

+ 1 - 1
packages/app/src/addons/serialize.test.ts

@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise<void> {
   })
 }
 
-describe.skip("SerializeAddon", () => {
+describe("SerializeAddon", () => {
   describe("ANSI color preservation", () => {
     test("should preserve text attributes (bold, italic, underline)", async () => {
       const { term, addon } = createTerminal()

+ 37 - 6
packages/app/src/addons/serialize.ts

@@ -56,6 +56,39 @@ interface IBufferCell {
   isDim(): boolean
 }
 
+type TerminalBuffers = {
+  active?: IBuffer
+  normal?: IBuffer
+  alternate?: IBuffer
+}
+
+const isRecord = (value: unknown): value is Record<string, unknown> => {
+  return typeof value === "object" && value !== null
+}
+
+const isBuffer = (value: unknown): value is IBuffer => {
+  if (!isRecord(value)) return false
+  if (typeof value.length !== "number") return false
+  if (typeof value.cursorX !== "number") return false
+  if (typeof value.cursorY !== "number") return false
+  if (typeof value.baseY !== "number") return false
+  if (typeof value.viewportY !== "number") return false
+  if (typeof value.getLine !== "function") return false
+  if (typeof value.getNullCell !== "function") return false
+  return true
+}
+
+const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
+  if (!isRecord(value)) return
+  const raw = value.buffer
+  if (!isRecord(raw)) return
+  const active = isBuffer(raw.active) ? raw.active : undefined
+  const normal = isBuffer(raw.normal) ? raw.normal : undefined
+  const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
+  if (!active && !normal) return
+  return { active, normal, alternate }
+}
+
 // ============================================================================
 // Types
 // ============================================================================
@@ -498,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
       throw new Error("Cannot use addon until it has been loaded")
     }
 
-    const terminal = this._terminal as any
-    const buffer = terminal.buffer
+    const buffer = getTerminalBuffers(this._terminal)
 
     if (!buffer) {
       return ""
     }
 
-    const normalBuffer = buffer.normal || buffer.active
+    const normalBuffer = buffer.normal ?? buffer.active
     const altBuffer = buffer.alternate
 
     if (!normalBuffer) {
@@ -533,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
       throw new Error("Cannot use addon until it has been loaded")
     }
 
-    const terminal = this._terminal as any
-    const buffer = terminal.buffer
+    const buffer = getTerminalBuffers(this._terminal)
 
     if (!buffer) {
       return ""
     }
 
-    const activeBuffer = buffer.active || buffer.normal
+    const activeBuffer = buffer.active ?? buffer.normal
     if (!activeBuffer) {
       return ""
     }

+ 41 - 41
packages/app/src/components/dialog-custom-provider.tsx

@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
     const key = apiKey && !env ? apiKey : undefined
 
     const idError = !providerID
-      ? "Provider ID is required"
+      ? language.t("provider.custom.error.providerID.required")
       : !PROVIDER_ID.test(providerID)
-        ? "Use lowercase letters, numbers, hyphens, or underscores"
+        ? language.t("provider.custom.error.providerID.format")
         : undefined
 
-    const nameError = !name ? "Display name is required" : undefined
+    const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
     const urlError = !baseURL
-      ? "Base URL is required"
+      ? language.t("provider.custom.error.baseURL.required")
       : !/^https?:\/\//.test(baseURL)
-        ? "Must start with http:// or https://"
+        ? language.t("provider.custom.error.baseURL.format")
         : undefined
 
     const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
     const existsError = idError
       ? undefined
       : existingProvider && !disabled
-        ? "That provider ID already exists"
+        ? language.t("provider.custom.error.providerID.exists")
         : undefined
 
     const seenModels = new Set<string>()
     const modelErrors = form.models.map((m) => {
       const id = m.id.trim()
       const modelIdError = !id
-        ? "Required"
+        ? language.t("provider.custom.error.required")
         : seenModels.has(id)
-          ? "Duplicate"
+          ? language.t("provider.custom.error.duplicate")
           : (() => {
               seenModels.add(id)
               return undefined
             })()
-      const modelNameError = !m.name.trim() ? "Required" : undefined
+      const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
       return { id: modelIdError, name: modelNameError }
     })
     const modelsValid = modelErrors.every((m) => !m.id && !m.name)
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
 
       if (!key && !value) return {}
       const keyError = !key
-        ? "Required"
+        ? language.t("provider.custom.error.required")
         : seenHeaders.has(key.toLowerCase())
-          ? "Duplicate"
+          ? language.t("provider.custom.error.duplicate")
           : (() => {
               seenHeaders.add(key.toLowerCase())
               return undefined
             })()
-      const valueError = !value ? "Required" : undefined
+      const valueError = !value ? language.t("provider.custom.error.required") : undefined
       return { key: keyError, value: valueError }
     })
     const headersValid = headerErrors.every((h) => !h.key && !h.value)
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
       <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
         <div class="px-2.5 flex gap-4 items-center">
           <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
-          <div class="text-16-medium text-text-strong">Custom provider</div>
+          <div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
         </div>
 
         <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
           <p class="text-14-regular text-text-base">
-            Configure an OpenAI-compatible provider. See the{" "}
+            {language.t("provider.custom.description.prefix")}
             <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
-              provider config docs
+              {language.t("provider.custom.description.link")}
             </Link>
-            .
+            {language.t("provider.custom.description.suffix")}
           </p>
 
           <div class="flex flex-col gap-4">
             <TextField
               autofocus
-              label="Provider ID"
-              placeholder="myprovider"
-              description="Lowercase letters, numbers, hyphens, or underscores"
+              label={language.t("provider.custom.field.providerID.label")}
+              placeholder={language.t("provider.custom.field.providerID.placeholder")}
+              description={language.t("provider.custom.field.providerID.description")}
               value={form.providerID}
               onChange={setForm.bind(null, "providerID")}
               validationState={errors.providerID ? "invalid" : undefined}
               error={errors.providerID}
             />
             <TextField
-              label="Display name"
-              placeholder="My AI Provider"
+              label={language.t("provider.custom.field.name.label")}
+              placeholder={language.t("provider.custom.field.name.placeholder")}
               value={form.name}
               onChange={setForm.bind(null, "name")}
               validationState={errors.name ? "invalid" : undefined}
               error={errors.name}
             />
             <TextField
-              label="Base URL"
-              placeholder="https://api.myprovider.com/v1"
+              label={language.t("provider.custom.field.baseURL.label")}
+              placeholder={language.t("provider.custom.field.baseURL.placeholder")}
               value={form.baseURL}
               onChange={setForm.bind(null, "baseURL")}
               validationState={errors.baseURL ? "invalid" : undefined}
               error={errors.baseURL}
             />
             <TextField
-              label="API key"
-              placeholder="API key"
-              description="Optional. Leave empty if you manage auth via headers."
+              label={language.t("provider.custom.field.apiKey.label")}
+              placeholder={language.t("provider.custom.field.apiKey.placeholder")}
+              description={language.t("provider.custom.field.apiKey.description")}
               value={form.apiKey}
               onChange={setForm.bind(null, "apiKey")}
             />
           </div>
 
           <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">Models</label>
+            <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
             <For each={form.models}>
               {(m, i) => (
                 <div class="flex gap-2 items-start">
                   <div class="flex-1">
                     <TextField
-                      label="ID"
+                      label={language.t("provider.custom.models.id.label")}
                       hideLabel
-                      placeholder="model-id"
+                      placeholder={language.t("provider.custom.models.id.placeholder")}
                       value={m.id}
                       onChange={(v) => setForm("models", i(), "id", v)}
                       validationState={errors.models[i()]?.id ? "invalid" : undefined}
@@ -344,9 +344,9 @@ export function DialogCustomProvider(props: Props) {
                   </div>
                   <div class="flex-1">
                     <TextField
-                      label="Name"
+                      label={language.t("provider.custom.models.name.label")}
                       hideLabel
-                      placeholder="Display Name"
+                      placeholder={language.t("provider.custom.models.name.placeholder")}
                       value={m.name}
                       onChange={(v) => setForm("models", i(), "name", v)}
                       validationState={errors.models[i()]?.name ? "invalid" : undefined}
@@ -360,26 +360,26 @@ export function DialogCustomProvider(props: Props) {
                     class="mt-1.5"
                     onClick={() => removeModel(i())}
                     disabled={form.models.length <= 1}
-                    aria-label="Remove model"
+                    aria-label={language.t("provider.custom.models.remove")}
                   />
                 </div>
               )}
             </For>
             <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
-              Add model
+              {language.t("provider.custom.models.add")}
             </Button>
           </div>
 
           <div class="flex flex-col gap-3">
-            <label class="text-12-medium text-text-weak">Headers (optional)</label>
+            <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
             <For each={form.headers}>
               {(h, i) => (
                 <div class="flex gap-2 items-start">
                   <div class="flex-1">
                     <TextField
-                      label="Header"
+                      label={language.t("provider.custom.headers.key.label")}
                       hideLabel
-                      placeholder="Header-Name"
+                      placeholder={language.t("provider.custom.headers.key.placeholder")}
                       value={h.key}
                       onChange={(v) => setForm("headers", i(), "key", v)}
                       validationState={errors.headers[i()]?.key ? "invalid" : undefined}
@@ -388,9 +388,9 @@ export function DialogCustomProvider(props: Props) {
                   </div>
                   <div class="flex-1">
                     <TextField
-                      label="Value"
+                      label={language.t("provider.custom.headers.value.label")}
                       hideLabel
-                      placeholder="value"
+                      placeholder={language.t("provider.custom.headers.value.placeholder")}
                       value={h.value}
                       onChange={(v) => setForm("headers", i(), "value", v)}
                       validationState={errors.headers[i()]?.value ? "invalid" : undefined}
@@ -404,18 +404,18 @@ export function DialogCustomProvider(props: Props) {
                     class="mt-1.5"
                     onClick={() => removeHeader(i())}
                     disabled={form.headers.length <= 1}
-                    aria-label="Remove header"
+                    aria-label={language.t("provider.custom.headers.remove")}
                   />
                 </div>
               )}
             </For>
             <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
-              Add header
+              {language.t("provider.custom.headers.add")}
             </Button>
           </div>
 
           <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
-            {form.saving ? "Saving..." : language.t("common.submit")}
+            {form.saving ? language.t("common.saving") : language.t("common.submit")}
           </Button>
         </form>
       </div>

+ 6 - 8
packages/app/src/components/dialog-select-model.tsx

@@ -87,11 +87,13 @@ const ModelList: Component<{
   )
 }
 
-export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
+type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
+
+export function ModelSelectorPopover(props: {
   provider?: string
   children?: JSX.Element
-  triggerAs?: T
-  triggerProps?: ComponentProps<T>
+  triggerAs?: ValidComponent
+  triggerProps?: ModelSelectorTriggerProps
 }) {
   const [store, setStore] = createStore<{
     open: boolean
@@ -176,11 +178,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
       placement="top-start"
       gutter={8}
     >
-      <Kobalte.Trigger
-        ref={(el) => setStore("trigger", el)}
-        as={props.triggerAs ?? "div"}
-        {...(props.triggerProps as any)}
-      >
+      <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
         {props.children}
       </Kobalte.Trigger>
       <Kobalte.Portal>

+ 20 - 78
packages/app/src/components/dialog-select-server.tsx

@@ -1,4 +1,4 @@
-import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } from "solid-js"
+import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
@@ -6,17 +6,15 @@ import { List } from "@opencode-ai/ui/list"
 import { Button } from "@opencode-ai/ui/button"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { TextField } from "@opencode-ai/ui/text-field"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
 import { usePlatform } from "@/context/platform"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { useNavigate } from "@solidjs/router"
 import { useLanguage } from "@/context/language"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
 interface AddRowProps {
   value: string
@@ -40,19 +38,6 @@ interface EditRowProps {
   onBlur: () => void
 }
 
-async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
-  const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
-  const sdk = createOpencodeClient({
-    baseUrl: url,
-    fetch: platform.fetch,
-    signal,
-  })
-  return sdk.global
-    .health()
-    .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
-    .catch(() => ({ healthy: false }))
-}
-
 function AddRow(props: AddRowProps) {
   return (
     <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
   const globalSDK = useGlobalSDK()
   const language = useLanguage()
   const [store, setStore] = createStore({
-    status: {} as Record<string, ServerStatus | undefined>,
+    status: {} as Record<string, ServerHealth | undefined>,
     addServer: {
       url: "",
       adding: false,
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
     { initialValue: null },
   )
   const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
+  const fetcher = platform.fetch ?? globalThis.fetch
 
   const looksComplete = (value: string) => {
     const normalized = normalizeServerUrl(value)
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
     if (!looksComplete(value)) return
     const normalized = normalizeServerUrl(value)
     if (!normalized) return
-    const result = await checkHealth(normalized, platform)
+    const result = await checkServerHealth(normalized, fetcher)
     setStatus(result.healthy)
   }
 
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
     if (!list.length) return list
     const active = current()
     const order = new Map(list.map((url, index) => [url, index] as const))
-    const rank = (value?: ServerStatus) => {
+    const rank = (value?: ServerHealth) => {
       if (value?.healthy === true) return 0
       if (value?.healthy === false) return 2
       return 1
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
   })
 
   async function refreshHealth() {
-    const results: Record<string, ServerStatus> = {}
+    const results: Record<string, ServerHealth> = {}
     await Promise.all(
       items().map(async (url) => {
-        results[url] = await checkHealth(url, platform)
+        results[url] = await checkServerHealth(url, fetcher)
       }),
     )
     setStore("status", reconcile(results))
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
 
     setStore("addServer", { adding: true, error: "" })
 
-    const result = await checkHealth(normalized, platform)
+    const result = await checkServerHealth(normalized, fetcher)
     setStore("addServer", { adding: false })
 
     if (!result.healthy) {
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
 
     setStore("editServer", { busy: true, error: "" })
 
-    const result = await checkHealth(normalized, platform)
+    const result = await checkServerHealth(normalized, fetcher)
     setStore("editServer", { busy: false })
 
     if (!result.healthy) {
@@ -413,35 +399,6 @@ export function DialogSelectServer() {
           }
         >
           {(i) => {
-            const [truncated, setTruncated] = createSignal(false)
-            let nameRef: HTMLSpanElement | undefined
-            let versionRef: HTMLSpanElement | undefined
-
-            const check = () => {
-              const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
-              const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
-              setTruncated(nameTruncated || versionTruncated)
-            }
-
-            createEffect(() => {
-              check()
-              window.addEventListener("resize", check)
-              onCleanup(() => window.removeEventListener("resize", check))
-            })
-
-            const tooltipValue = () => {
-              const name = serverDisplayName(i)
-              const version = store.status[i]?.version
-              return (
-                <span class="flex items-center gap-2">
-                  <span>{name}</span>
-                  <Show when={version}>
-                    <span class="text-text-invert-base">{version}</span>
-                  </Show>
-                </span>
-              )
-            }
-
             return (
               <div class="flex items-center gap-3 min-w-0 flex-1 group/item">
                 <Show
@@ -459,34 +416,19 @@ export function DialogSelectServer() {
                     />
                   }
                 >
-                  <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
-                    <div
-                      class="flex items-center gap-3 px-4 min-w-0 flex-1"
-                      classList={{ "opacity-50": store.status[i]?.healthy === false }}
-                    >
-                      <div
-                        classList={{
-                          "size-1.5 rounded-full shrink-0": true,
-                          "bg-icon-success-base": store.status[i]?.healthy === true,
-                          "bg-icon-critical-base": store.status[i]?.healthy === false,
-                          "bg-border-weak-base": store.status[i] === undefined,
-                        }}
-                      />
-                      <span ref={nameRef} class="truncate">
-                        {serverDisplayName(i)}
-                      </span>
-                      <Show when={store.status[i]?.version}>
-                        <span ref={versionRef} class="text-text-weak text-14-regular truncate">
-                          {store.status[i]?.version}
-                        </span>
-                      </Show>
+                  <ServerRow
+                    url={i}
+                    status={store.status[i]}
+                    dimmed={store.status[i]?.healthy === false}
+                    class="flex items-center gap-3 px-4 min-w-0 flex-1"
+                    badge={
                       <Show when={defaultUrl() === i}>
                         <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
                           {language.t("dialog.server.status.default")}
                         </span>
                       </Show>
-                    </div>
-                  </Tooltip>
+                    }
+                  />
                 </Show>
                 <Show when={store.editServer.id !== i}>
                   <div class="flex items-center justify-center gap-5 pl-4">

+ 56 - 258
packages/app/src/components/prompt-input.tsx

@@ -19,7 +19,6 @@ import { useSDK } from "@/context/sdk"
 import { useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
-import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
@@ -27,9 +26,7 @@ import type { IconName } from "@opencode-ai/ui/icons/provider"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
-import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { ImagePreview } from "@opencode-ai/ui/image-preview"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
@@ -42,6 +39,12 @@ import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge
 import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
 import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
 import { createPromptSubmit } from "./prompt-input/submit"
+import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
+import { PromptContextItems } from "./prompt-input/context-items"
+import { PromptImageAttachments } from "./prompt-input/image-attachments"
+import { PromptDragOverlay } from "./prompt-input/drag-overlay"
+import { promptPlaceholder } from "./prompt-input/placeholder"
+import { ImagePreview } from "@opencode-ai/ui/image-preview"
 
 interface PromptInputProps {
   class?: string
@@ -79,16 +82,6 @@ const EXAMPLES = [
   "prompt.example.25",
 ] as const
 
-interface SlashCommand {
-  id: string
-  trigger: string
-  title: string
-  description?: string
-  keybind?: string
-  type: "builtin" | "custom"
-  source?: "command" | "mcp" | "skill"
-}
-
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const sdk = useSDK()
   const sync = useSync()
@@ -203,8 +196,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       },
   )
   const working = createMemo(() => status()?.type !== "idle")
-  const imageAttachments = createMemo(
-    () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
+  const imageAttachments = createMemo(() =>
+    prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
   )
 
   const [store, setStore] = createStore<{
@@ -224,6 +217,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     mode: "normal",
     applyingHistory: false,
   })
+  const placeholder = createMemo(() =>
+    promptPlaceholder({
+      mode: store.mode,
+      commentCount: commentCount(),
+      example: language.t(EXAMPLES[store.placeholder]),
+      t: (key, params) => language.t(key as Parameters<typeof language.t>[0], params as never),
+    }),
+  )
 
   const MAX_HISTORY = 100
   const [history, setHistory] = persisted(
@@ -296,10 +297,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!isFocused()) setComposing(false)
   })
 
-  type AtOption =
-    | { type: "agent"; name: string; display: string }
-    | { type: "file"; path: string; display: string; recent?: boolean }
-
   const agentList = createMemo(() =>
     sync.data.agent
       .filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -509,7 +506,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     on(
       () => prompt.current(),
       (currentParts) => {
-        const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
+        const inputParts = currentParts.filter((part) => part.type !== "image")
 
         if (mirror.input) {
           mirror.input = false
@@ -928,110 +925,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
-      <Show when={store.popover}>
-        <div
-          ref={(el) => {
-            if (store.popover === "slash") slashPopoverRef = el
-          }}
-          class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
-                 overflow-auto no-scrollbar flex flex-col p-2 rounded-md
-                 border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
-          onMouseDown={(e) => e.preventDefault()}
-        >
-          <Switch>
-            <Match when={store.popover === "at"}>
-              <Show
-                when={atFlat().length > 0}
-                fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyResults")}</div>}
-              >
-                <For each={atFlat().slice(0, 10)}>
-                  {(item) => (
-                    <button
-                      classList={{
-                        "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
-                        "bg-surface-raised-base-hover": atActive() === atKey(item),
-                      }}
-                      onClick={() => handleAtSelect(item)}
-                      onMouseEnter={() => setAtActive(atKey(item))}
-                    >
-                      <Show
-                        when={item.type === "agent"}
-                        fallback={
-                          <>
-                            <FileIcon
-                              node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
-                              class="shrink-0 size-4"
-                            />
-                            <div class="flex items-center text-14-regular min-w-0">
-                              <span class="text-text-weak whitespace-nowrap truncate min-w-0">
-                                {(() => {
-                                  const path = (item as { type: "file"; path: string }).path
-                                  return path.endsWith("/") ? path : getDirectory(path)
-                                })()}
-                              </span>
-                              <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
-                                <span class="text-text-strong whitespace-nowrap">
-                                  {getFilename((item as { type: "file"; path: string }).path)}
-                                </span>
-                              </Show>
-                            </div>
-                          </>
-                        }
-                      >
-                        <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
-                        <span class="text-14-regular text-text-strong whitespace-nowrap">
-                          @{(item as { type: "agent"; name: string }).name}
-                        </span>
-                      </Show>
-                    </button>
-                  )}
-                </For>
-              </Show>
-            </Match>
-            <Match when={store.popover === "slash"}>
-              <Show
-                when={slashFlat().length > 0}
-                fallback={<div class="text-text-weak px-2 py-1">{language.t("prompt.popover.emptyCommands")}</div>}
-              >
-                <For each={slashFlat()}>
-                  {(cmd) => (
-                    <button
-                      data-slash-id={cmd.id}
-                      classList={{
-                        "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
-                        "bg-surface-raised-base-hover": slashActive() === cmd.id,
-                      }}
-                      onClick={() => handleSlashSelect(cmd)}
-                      onMouseEnter={() => setSlashActive(cmd.id)}
-                    >
-                      <div class="flex items-center gap-2 min-w-0">
-                        <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
-                        <Show when={cmd.description}>
-                          <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
-                        </Show>
-                      </div>
-                      <div class="flex items-center gap-2 shrink-0">
-                        <Show when={cmd.type === "custom" && cmd.source !== "command"}>
-                          <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
-                            {cmd.source === "skill"
-                              ? language.t("prompt.slash.badge.skill")
-                              : cmd.source === "mcp"
-                                ? language.t("prompt.slash.badge.mcp")
-                                : language.t("prompt.slash.badge.custom")}
-                          </span>
-                        </Show>
-                        <Show when={command.keybind(cmd.id)}>
-                          <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
-                        </Show>
-                      </div>
-                    </button>
-                  )}
-                </For>
-              </Show>
-            </Match>
-          </Switch>
-        </div>
-      </Show>
+      <PromptPopover
+        popover={store.popover}
+        setSlashPopoverRef={(el) => (slashPopoverRef = el)}
+        atFlat={atFlat()}
+        atActive={atActive() ?? undefined}
+        atKey={atKey}
+        setAtActive={setAtActive}
+        onAtSelect={handleAtSelect}
+        slashFlat={slashFlat()}
+        slashActive={slashActive() ?? undefined}
+        setSlashActive={setSlashActive}
+        onSlashSelect={handleSlashSelect}
+        commandKeybind={command.keybind}
+        t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+      />
       <form
         onSubmit={handleSubmit}
         classList={{
@@ -1042,124 +950,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           [props.class ?? ""]: !!props.class,
         }}
       >
-        <Show when={store.dragging}>
-          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
-            <div class="flex flex-col items-center gap-2 text-text-weak">
-              <Icon name="photo" class="size-8" />
-              <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
-            </div>
-          </div>
-        </Show>
-        <Show when={prompt.context.items().length > 0}>
-          <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
-            <For each={prompt.context.items()}>
-              {(item) => {
-                const active = () => {
-                  const a = comments.active()
-                  return !!item.commentID && item.commentID === a?.id && item.path === a?.file
-                }
-                return (
-                  <Tooltip
-                    value={
-                      <span class="flex max-w-[300px]">
-                        <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
-                          {getDirectory(item.path)}
-                        </span>
-                        <span class="shrink-0">{getFilename(item.path)}</span>
-                      </span>
-                    }
-                    placement="top"
-                    openDelay={2000}
-                  >
-                    <div
-                      classList={{
-                        "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
-                        "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !active(),
-                        "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
-                          active(),
-                        "bg-background-stronger": !active(),
-                      }}
-                      onClick={() => {
-                        openComment(item)
-                      }}
-                    >
-                      <div class="flex items-center gap-1.5">
-                        <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
-                        <div class="flex items-center text-11-regular min-w-0 font-medium">
-                          <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
-                          <Show when={item.selection}>
-                            {(sel) => (
-                              <span class="text-text-weak whitespace-nowrap shrink-0">
-                                {sel().startLine === sel().endLine
-                                  ? `:${sel().startLine}`
-                                  : `:${sel().startLine}-${sel().endLine}`}
-                              </span>
-                            )}
-                          </Show>
-                        </div>
-                        <IconButton
-                          type="button"
-                          icon="close-small"
-                          variant="ghost"
-                          class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
-                          onClick={(e) => {
-                            e.stopPropagation()
-                            if (item.commentID) comments.remove(item.path, item.commentID)
-                            prompt.context.remove(item.key)
-                          }}
-                          aria-label={language.t("prompt.context.removeFile")}
-                        />
-                      </div>
-                      <Show when={item.comment}>
-                        {(comment) => (
-                          <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>
-                        )}
-                      </Show>
-                    </div>
-                  </Tooltip>
-                )
-              }}
-            </For>
-          </div>
-        </Show>
-        <Show when={imageAttachments().length > 0}>
-          <div class="flex flex-wrap gap-2 px-3 pt-3">
-            <For each={imageAttachments()}>
-              {(attachment) => (
-                <div class="relative group">
-                  <Show
-                    when={attachment.mime.startsWith("image/")}
-                    fallback={
-                      <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
-                        <Icon name="folder" class="size-6 text-text-weak" />
-                      </div>
-                    }
-                  >
-                    <img
-                      src={attachment.dataUrl}
-                      alt={attachment.filename}
-                      class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
-                      onClick={() =>
-                        dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
-                      }
-                    />
-                  </Show>
-                  <button
-                    type="button"
-                    onClick={() => removeImageAttachment(attachment.id)}
-                    class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
-                    aria-label={language.t("prompt.attachment.remove")}
-                  >
-                    <Icon name="close" class="size-3 text-text-weak" />
-                  </button>
-                  <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
-                    <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
-                  </div>
-                </div>
-              )}
-            </For>
-          </div>
-        </Show>
+        <PromptDragOverlay dragging={store.dragging} label={language.t("prompt.dropzone.label")} />
+        <PromptContextItems
+          items={prompt.context.items()}
+          active={(item) => {
+            const active = comments.active()
+            return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+          }}
+          openComment={openComment}
+          remove={(item) => {
+            if (item.commentID) comments.remove(item.path, item.commentID)
+            prompt.context.remove(item.key)
+          }}
+          t={(key) => language.t(key as Parameters<typeof language.t>[0])}
+        />
+        <PromptImageAttachments
+          attachments={imageAttachments()}
+          onOpen={(attachment) =>
+            dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
+          }
+          onRemove={removeImageAttachment}
+          removeLabel={language.t("prompt.attachment.remove")}
+        />
         <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
           <div
             data-component="prompt-input"
@@ -1169,15 +981,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             }}
             role="textbox"
             aria-multiline="true"
-            aria-label={
-              store.mode === "shell"
-                ? language.t("prompt.placeholder.shell")
-                : commentCount() > 1
-                  ? language.t("prompt.placeholder.summarizeComments")
-                  : commentCount() === 1
-                    ? language.t("prompt.placeholder.summarizeComment")
-                    : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
-            }
+            aria-label={placeholder()}
             contenteditable="true"
             onInput={handleInput}
             onPaste={handlePaste}
@@ -1194,13 +998,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           />
           <Show when={!prompt.dirty()}>
             <div class="absolute top-0 inset-x-0 p-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
-              {store.mode === "shell"
-                ? language.t("prompt.placeholder.shell")
-                : commentCount() > 1
-                  ? language.t("prompt.placeholder.summarizeComments")
-                  : commentCount() === 1
-                    ? language.t("prompt.placeholder.summarizeComment")
-                    : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
+              {placeholder()}
             </div>
           </Show>
         </div>

+ 67 - 0
packages/app/src/components/prompt-input/build-request-parts.test.ts

@@ -0,0 +1,67 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { buildRequestParts } from "./build-request-parts"
+
+describe("buildRequestParts", () => {
+  test("builds typed request and optimistic parts without cast path", () => {
+    const prompt: Prompt = [
+      { type: "text", content: "hello", start: 0, end: 5 },
+      {
+        type: "file",
+        path: "src/foo.ts",
+        content: "@src/foo.ts",
+        start: 5,
+        end: 16,
+        selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
+      },
+      { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
+    ]
+
+    const result = buildRequestParts({
+      prompt,
+      context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
+      images: [
+        { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+      ],
+      text: "hello @src/foo.ts @planner",
+      messageID: "msg_1",
+      sessionID: "ses_1",
+      sessionDirectory: "/repo",
+    })
+
+    expect(result.requestParts[0]?.type).toBe("text")
+    expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
+    expect(
+      result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
+    ).toBe(true)
+    expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+
+    expect(result.optimisticParts).toHaveLength(result.requestParts.length)
+    expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
+  })
+
+  test("deduplicates context files when prompt already includes same path", () => {
+    const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
+
+    const result = buildRequestParts({
+      prompt,
+      context: [
+        { key: "ctx:dup", type: "file", path: "src/foo.ts" },
+        { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
+      ],
+      images: [],
+      text: "@src/foo.ts",
+      messageID: "msg_2",
+      sessionID: "ses_2",
+      sessionDirectory: "/repo",
+    })
+
+    const fooFiles = result.requestParts.filter(
+      (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
+    )
+    const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
+
+    expect(fooFiles).toHaveLength(2)
+    expect(synthetic).toHaveLength(1)
+  })
+})

+ 174 - 0
packages/app/src/components/prompt-input/build-request-parts.ts

@@ -0,0 +1,174 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
+import type { FileSelection } from "@/context/file"
+import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
+import { Identifier } from "@/utils/id"
+
+type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
+
+type ContextFile = {
+  key: string
+  type: "file"
+  path: string
+  selection?: FileSelection
+  comment?: string
+  commentID?: string
+  commentOrigin?: "review" | "file"
+  preview?: string
+}
+
+type BuildRequestPartsInput = {
+  prompt: Prompt
+  context: ContextFile[]
+  images: ImageAttachmentPart[]
+  text: string
+  messageID: string
+  sessionID: string
+  sessionDirectory: string
+}
+
+const absolute = (directory: string, path: string) =>
+  path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
+
+const fileQuery = (selection: FileSelection | undefined) =>
+  selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
+
+const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
+const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
+
+const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+  const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+  const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+  const range =
+    start === undefined || end === undefined
+      ? "this file"
+      : start === end
+        ? `line ${start}`
+        : `lines ${start} through ${end}`
+  return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+}
+
+const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
+  if (part.type === "text") {
+    return {
+      id: part.id,
+      type: "text",
+      text: part.text,
+      synthetic: part.synthetic,
+      ignored: part.ignored,
+      time: part.time,
+      metadata: part.metadata,
+      sessionID,
+      messageID,
+    }
+  }
+  if (part.type === "file") {
+    return {
+      id: part.id,
+      type: "file",
+      mime: part.mime,
+      filename: part.filename,
+      url: part.url,
+      source: part.source,
+      sessionID,
+      messageID,
+    }
+  }
+  return {
+    id: part.id,
+    type: "agent",
+    name: part.name,
+    source: part.source,
+    sessionID,
+    messageID,
+  }
+}
+
+export function buildRequestParts(input: BuildRequestPartsInput) {
+  const requestParts: PromptRequestPart[] = [
+    {
+      id: Identifier.ascending("part"),
+      type: "text",
+      text: input.text,
+    },
+  ]
+
+  const files = input.prompt.filter(isFileAttachment).map((attachment) => {
+    const path = absolute(input.sessionDirectory, attachment.path)
+    return {
+      id: Identifier.ascending("part"),
+      type: "file",
+      mime: "text/plain",
+      url: `file://${path}${fileQuery(attachment.selection)}`,
+      filename: getFilename(attachment.path),
+      source: {
+        type: "file",
+        text: {
+          value: attachment.content,
+          start: attachment.start,
+          end: attachment.end,
+        },
+        path,
+      },
+    } satisfies PromptRequestPart
+  })
+
+  const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
+    return {
+      id: Identifier.ascending("part"),
+      type: "agent",
+      name: attachment.name,
+      source: {
+        value: attachment.content,
+        start: attachment.start,
+        end: attachment.end,
+      },
+    } satisfies PromptRequestPart
+  })
+
+  const used = new Set(files.map((part) => part.url))
+  const context = input.context.flatMap((item) => {
+    const path = absolute(input.sessionDirectory, item.path)
+    const url = `file://${path}${fileQuery(item.selection)}`
+    const comment = item.comment?.trim()
+    if (!comment && used.has(url)) return []
+    used.add(url)
+
+    const filePart = {
+      id: Identifier.ascending("part"),
+      type: "file",
+      mime: "text/plain",
+      url,
+      filename: getFilename(item.path),
+    } satisfies PromptRequestPart
+
+    if (!comment) return [filePart]
+
+    return [
+      {
+        id: Identifier.ascending("part"),
+        type: "text",
+        text: commentNote(item.path, item.selection, comment),
+        synthetic: true,
+      } satisfies PromptRequestPart,
+      filePart,
+    ]
+  })
+
+  const images = input.images.map((attachment) => {
+    return {
+      id: Identifier.ascending("part"),
+      type: "file",
+      mime: attachment.mime,
+      url: attachment.dataUrl,
+      filename: attachment.filename,
+    } satisfies PromptRequestPart
+  })
+
+  requestParts.push(...files, ...context, ...agents, ...images)
+
+  return {
+    requestParts,
+    optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
+  }
+}

+ 82 - 0
packages/app/src/components/prompt-input/context-items.tsx

@@ -0,0 +1,82 @@
+import { Component, For, Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
+import type { ContextItem } from "@/context/prompt"
+
+type PromptContextItem = ContextItem & { key: string }
+
+type ContextItemsProps = {
+  items: PromptContextItem[]
+  active: (item: PromptContextItem) => boolean
+  openComment: (item: PromptContextItem) => void
+  remove: (item: PromptContextItem) => void
+  t: (key: string) => string
+}
+
+export const PromptContextItems: Component<ContextItemsProps> = (props) => {
+  return (
+    <Show when={props.items.length > 0}>
+      <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
+        <For each={props.items}>
+          {(item) => (
+            <Tooltip
+              value={
+                <span class="flex max-w-[300px]">
+                  <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
+                    {getDirectory(item.path)}
+                  </span>
+                  <span class="shrink-0">{getFilename(item.path)}</span>
+                </span>
+              }
+              placement="top"
+              openDelay={2000}
+            >
+              <div
+                classList={{
+                  "group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
+                  "cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
+                  "cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
+                    props.active(item),
+                  "bg-background-stronger": !props.active(item),
+                }}
+                onClick={() => props.openComment(item)}
+              >
+                <div class="flex items-center gap-1.5">
+                  <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+                  <div class="flex items-center text-11-regular min-w-0 font-medium">
+                    <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
+                    <Show when={item.selection}>
+                      {(sel) => (
+                        <span class="text-text-weak whitespace-nowrap shrink-0">
+                          {sel().startLine === sel().endLine
+                            ? `:${sel().startLine}`
+                            : `:${sel().startLine}-${sel().endLine}`}
+                        </span>
+                      )}
+                    </Show>
+                  </div>
+                  <IconButton
+                    type="button"
+                    icon="close-small"
+                    variant="ghost"
+                    class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
+                    onClick={(e) => {
+                      e.stopPropagation()
+                      props.remove(item)
+                    }}
+                    aria-label={props.t("prompt.context.removeFile")}
+                  />
+                </div>
+                <Show when={item.comment}>
+                  {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
+                </Show>
+              </div>
+            </Tooltip>
+          )}
+        </For>
+      </div>
+    </Show>
+  )
+}

+ 20 - 0
packages/app/src/components/prompt-input/drag-overlay.tsx

@@ -0,0 +1,20 @@
+import { Component, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+
+type PromptDragOverlayProps = {
+  dragging: boolean
+  label: string
+}
+
+export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
+  return (
+    <Show when={props.dragging}>
+      <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+        <div class="flex flex-col items-center gap-2 text-text-weak">
+          <Icon name="photo" class="size-8" />
+          <span class="text-14-regular">{props.label}</span>
+        </div>
+      </div>
+    </Show>
+  )
+}

+ 51 - 0
packages/app/src/components/prompt-input/image-attachments.tsx

@@ -0,0 +1,51 @@
+import { Component, For, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import type { ImageAttachmentPart } from "@/context/prompt"
+
+type PromptImageAttachmentsProps = {
+  attachments: ImageAttachmentPart[]
+  onOpen: (attachment: ImageAttachmentPart) => void
+  onRemove: (id: string) => void
+  removeLabel: string
+}
+
+export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
+  return (
+    <Show when={props.attachments.length > 0}>
+      <div class="flex flex-wrap gap-2 px-3 pt-3">
+        <For each={props.attachments}>
+          {(attachment) => (
+            <div class="relative group">
+              <Show
+                when={attachment.mime.startsWith("image/")}
+                fallback={
+                  <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+                    <Icon name="folder" class="size-6 text-text-weak" />
+                  </div>
+                }
+              >
+                <img
+                  src={attachment.dataUrl}
+                  alt={attachment.filename}
+                  class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
+                  onClick={() => props.onOpen(attachment)}
+                />
+              </Show>
+              <button
+                type="button"
+                onClick={() => props.onRemove(attachment.id)}
+                class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+                aria-label={props.removeLabel}
+              >
+                <Icon name="close" class="size-3 text-text-weak" />
+              </button>
+              <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+                <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+              </div>
+            </div>
+          )}
+        </For>
+      </div>
+    </Show>
+  )
+}

+ 35 - 0
packages/app/src/components/prompt-input/placeholder.test.ts

@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { promptPlaceholder } from "./placeholder"
+
+describe("promptPlaceholder", () => {
+  const t = (key: string, params?: Record<string, string>) => `${key}${params?.example ? `:${params.example}` : ""}`
+
+  test("returns shell placeholder in shell mode", () => {
+    const value = promptPlaceholder({
+      mode: "shell",
+      commentCount: 0,
+      example: "example",
+      t,
+    })
+    expect(value).toBe("prompt.placeholder.shell")
+  })
+
+  test("returns summarize placeholders for comment context", () => {
+    expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
+      "prompt.placeholder.summarizeComment",
+    )
+    expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
+      "prompt.placeholder.summarizeComments",
+    )
+  })
+
+  test("returns default placeholder with example", () => {
+    const value = promptPlaceholder({
+      mode: "normal",
+      commentCount: 0,
+      example: "translated-example",
+      t,
+    })
+    expect(value).toBe("prompt.placeholder.normal:translated-example")
+  })
+})

+ 13 - 0
packages/app/src/components/prompt-input/placeholder.ts

@@ -0,0 +1,13 @@
+type PromptPlaceholderInput = {
+  mode: "normal" | "shell"
+  commentCount: number
+  example: string
+  t: (key: string, params?: Record<string, string>) => string
+}
+
+export function promptPlaceholder(input: PromptPlaceholderInput) {
+  if (input.mode === "shell") return input.t("prompt.placeholder.shell")
+  if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
+  if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
+  return input.t("prompt.placeholder.normal", { example: input.example })
+}

+ 144 - 0
packages/app/src/components/prompt-input/slash-popover.tsx

@@ -0,0 +1,144 @@
+import { Component, For, Match, Show, Switch } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export type AtOption =
+  | { type: "agent"; name: string; display: string }
+  | { type: "file"; path: string; display: string; recent?: boolean }
+
+export interface SlashCommand {
+  id: string
+  trigger: string
+  title: string
+  description?: string
+  keybind?: string
+  type: "builtin" | "custom"
+  source?: "command" | "mcp" | "skill"
+}
+
+type PromptPopoverProps = {
+  popover: "at" | "slash" | null
+  setSlashPopoverRef: (el: HTMLDivElement) => void
+  atFlat: AtOption[]
+  atActive?: string
+  atKey: (item: AtOption) => string
+  setAtActive: (id: string) => void
+  onAtSelect: (item: AtOption) => void
+  slashFlat: SlashCommand[]
+  slashActive?: string
+  setSlashActive: (id: string) => void
+  onSlashSelect: (item: SlashCommand) => void
+  commandKeybind: (id: string) => string | undefined
+  t: (key: string) => string
+}
+
+export const PromptPopover: Component<PromptPopoverProps> = (props) => {
+  return (
+    <Show when={props.popover}>
+      <div
+        ref={(el) => {
+          if (props.popover === "slash") props.setSlashPopoverRef(el)
+        }}
+        class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
+                 overflow-auto no-scrollbar flex flex-col p-2 rounded-md
+                 border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
+        onMouseDown={(e) => e.preventDefault()}
+      >
+        <Switch>
+          <Match when={props.popover === "at"}>
+            <Show
+              when={props.atFlat.length > 0}
+              fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
+            >
+              <For each={props.atFlat.slice(0, 10)}>
+                {(item) => (
+                  <button
+                    classList={{
+                      "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+                      "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
+                    }}
+                    onClick={() => props.onAtSelect(item)}
+                    onMouseEnter={() => props.setAtActive(props.atKey(item))}
+                  >
+                    <Show
+                      when={item.type === "agent"}
+                      fallback={
+                        <>
+                          <FileIcon
+                            node={{ path: item.type === "file" ? item.path : "", type: "file" }}
+                            class="shrink-0 size-4"
+                          />
+                          <div class="flex items-center text-14-regular min-w-0">
+                            <span class="text-text-weak whitespace-nowrap truncate min-w-0">
+                              {item.type === "file"
+                                ? item.path.endsWith("/")
+                                  ? item.path
+                                  : getDirectory(item.path)
+                                : ""}
+                            </span>
+                            <Show when={item.type === "file" && !item.path.endsWith("/")}>
+                              <span class="text-text-strong whitespace-nowrap">
+                                {item.type === "file" ? getFilename(item.path) : ""}
+                              </span>
+                            </Show>
+                          </div>
+                        </>
+                      }
+                    >
+                      <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+                      <span class="text-14-regular text-text-strong whitespace-nowrap">
+                        @{item.type === "agent" ? item.name : ""}
+                      </span>
+                    </Show>
+                  </button>
+                )}
+              </For>
+            </Show>
+          </Match>
+          <Match when={props.popover === "slash"}>
+            <Show
+              when={props.slashFlat.length > 0}
+              fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyCommands")}</div>}
+            >
+              <For each={props.slashFlat}>
+                {(cmd) => (
+                  <button
+                    data-slash-id={cmd.id}
+                    classList={{
+                      "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+                      "bg-surface-raised-base-hover": props.slashActive === cmd.id,
+                    }}
+                    onClick={() => props.onSlashSelect(cmd)}
+                    onMouseEnter={() => props.setSlashActive(cmd.id)}
+                  >
+                    <div class="flex items-center gap-2 min-w-0">
+                      <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+                      <Show when={cmd.description}>
+                        <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+                      </Show>
+                    </div>
+                    <div class="flex items-center gap-2 shrink-0">
+                      <Show when={cmd.type === "custom" && cmd.source !== "command"}>
+                        <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+                          {cmd.source === "skill"
+                            ? props.t("prompt.slash.badge.skill")
+                            : cmd.source === "mcp"
+                              ? props.t("prompt.slash.badge.mcp")
+                              : props.t("prompt.slash.badge.custom")}
+                        </span>
+                      </Show>
+                      <Show when={props.commandKeybind(cmd.id)}>
+                        <span class="text-12-regular text-text-subtle">{props.commandKeybind(cmd.id)}</span>
+                      </Show>
+                    </div>
+                  </button>
+                )}
+              </For>
+            </Show>
+          </Match>
+        </Switch>
+      </div>
+    </Show>
+  )
+}

+ 23 - 199
packages/app/src/components/prompt-input/submit.ts

@@ -1,19 +1,10 @@
 import { Accessor } from "solid-js"
-import { produce } from "solid-js/store"
 import { useNavigate, useParams } from "@solidjs/router"
-import { getFilename } from "@opencode-ai/util/path"
-import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
-import { Binary } from "@opencode-ai/util/binary"
+import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
 import { showToast } from "@opencode-ai/ui/toast"
 import { base64Encode } from "@opencode-ai/util/encode"
 import { useLocal } from "@/context/local"
-import {
-  usePrompt,
-  type AgentPart,
-  type FileAttachmentPart,
-  type ImageAttachmentPart,
-  type Prompt,
-} from "@/context/prompt"
+import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
@@ -24,6 +15,7 @@ import { Identifier } from "@/utils/id"
 import { Worktree as WorktreeState } from "@/utils/worktree"
 import type { FileSelection } from "@/context/file"
 import { setCursorPosition } from "./editor-dom"
+import { buildRequestParts } from "./build-request-parts"
 
 type PendingPrompt = {
   abort: AbortController
@@ -290,138 +282,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       }
     }
 
-    const toAbsolutePath = (path: string) =>
-      path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
-
-    const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
-    const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
-
-    const fileAttachmentParts = fileAttachments.map((attachment) => {
-      const absolute = toAbsolutePath(attachment.path)
-      const query = attachment.selection
-        ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
-        : ""
-      return {
-        id: Identifier.ascending("part"),
-        type: "file" as const,
-        mime: "text/plain",
-        url: `file://${absolute}${query}`,
-        filename: getFilename(attachment.path),
-        source: {
-          type: "file" as const,
-          text: {
-            value: attachment.content,
-            start: attachment.start,
-            end: attachment.end,
-          },
-          path: absolute,
-        },
-      }
-    })
-
-    const agentAttachmentParts = agentAttachments.map((attachment) => ({
-      id: Identifier.ascending("part"),
-      type: "agent" as const,
-      name: attachment.name,
-      source: {
-        value: attachment.content,
-        start: attachment.start,
-        end: attachment.end,
-      },
-    }))
-
-    const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
-
     const context = prompt.context.items().slice()
     const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
 
-    const contextParts: Array<
-      | {
-          id: string
-          type: "text"
-          text: string
-          synthetic?: boolean
-        }
-      | {
-          id: string
-          type: "file"
-          mime: string
-          url: string
-          filename?: string
-        }
-    > = []
-
-    const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
-      const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
-      const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
-      const range =
-        start === undefined || end === undefined
-          ? "this file"
-          : start === end
-            ? `line ${start}`
-            : `lines ${start} through ${end}`
-
-      return `The user made the following comment regarding ${range} of ${path}: ${comment}`
-    }
-
-    const addContextFile = (item: { path: string; selection?: FileSelection; comment?: string }) => {
-      const absolute = toAbsolutePath(item.path)
-      const query = item.selection ? `?start=${item.selection.startLine}&end=${item.selection.endLine}` : ""
-      const url = `file://${absolute}${query}`
-
-      const comment = item.comment?.trim()
-      if (!comment && usedUrls.has(url)) return
-      usedUrls.add(url)
-
-      if (comment) {
-        contextParts.push({
-          id: Identifier.ascending("part"),
-          type: "text",
-          text: commentNote(item.path, item.selection, comment),
-          synthetic: true,
-        })
-      }
-
-      contextParts.push({
-        id: Identifier.ascending("part"),
-        type: "file",
-        mime: "text/plain",
-        url,
-        filename: getFilename(item.path),
-      })
-    }
-
-    for (const item of context) {
-      if (item.type !== "file") continue
-      addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
-    }
-
-    const imageAttachmentParts = images.map((attachment) => ({
-      id: Identifier.ascending("part"),
-      type: "file" as const,
-      mime: attachment.mime,
-      url: attachment.dataUrl,
-      filename: attachment.filename,
-    }))
-
     const messageID = Identifier.ascending("message")
-    const requestParts = [
-      {
-        id: Identifier.ascending("part"),
-        type: "text" as const,
-        text,
-      },
-      ...fileAttachmentParts,
-      ...contextParts,
-      ...agentAttachmentParts,
-      ...imageAttachmentParts,
-    ]
-
-    const optimisticParts = requestParts.map((part) => ({
-      ...part,
+    const { requestParts, optimisticParts } = buildRequestParts({
+      prompt: currentPrompt,
+      context,
+      images,
+      text,
       sessionID: session.id,
       messageID,
-    })) as unknown as Part[]
+      sessionDirectory,
+    })
 
     const optimisticMessage: Message = {
       id: messageID,
@@ -432,69 +305,20 @@ export function createPromptSubmit(input: PromptSubmitInput) {
       model,
     }
 
-    const addOptimisticMessage = () => {
-      if (sessionDirectory === projectDirectory) {
-        sync.set(
-          produce((draft) => {
-            const messages = draft.message[session.id]
-            if (!messages) {
-              draft.message[session.id] = [optimisticMessage]
-            } else {
-              const result = Binary.search(messages, messageID, (m) => m.id)
-              messages.splice(result.index, 0, optimisticMessage)
-            }
-            draft.part[messageID] = optimisticParts
-              .filter((part) => !!part?.id)
-              .slice()
-              .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-          }),
-        )
-        return
-      }
-
-      globalSync.child(sessionDirectory)[1](
-        produce((draft) => {
-          const messages = draft.message[session.id]
-          if (!messages) {
-            draft.message[session.id] = [optimisticMessage]
-          } else {
-            const result = Binary.search(messages, messageID, (m) => m.id)
-            messages.splice(result.index, 0, optimisticMessage)
-          }
-          draft.part[messageID] = optimisticParts
-            .filter((part) => !!part?.id)
-            .slice()
-            .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
-        }),
-      )
-    }
-
-    const removeOptimisticMessage = () => {
-      if (sessionDirectory === projectDirectory) {
-        sync.set(
-          produce((draft) => {
-            const messages = draft.message[session.id]
-            if (messages) {
-              const result = Binary.search(messages, messageID, (m) => m.id)
-              if (result.found) messages.splice(result.index, 1)
-            }
-            delete draft.part[messageID]
-          }),
-        )
-        return
-      }
+    const addOptimisticMessage = () =>
+      sync.session.optimistic.add({
+        directory: sessionDirectory,
+        sessionID: session.id,
+        message: optimisticMessage,
+        parts: optimisticParts,
+      })
 
-      globalSync.child(sessionDirectory)[1](
-        produce((draft) => {
-          const messages = draft.message[session.id]
-          if (messages) {
-            const result = Binary.search(messages, messageID, (m) => m.id)
-            if (result.found) messages.splice(result.index, 1)
-          }
-          delete draft.part[messageID]
-        }),
-      )
-    }
+    const removeOptimisticMessage = () =>
+      sync.session.optimistic.remove({
+        directory: sessionDirectory,
+        sessionID: session.id,
+        messageID,
+      })
 
     removeCommentItems(commentItems)
     clearInput()

+ 77 - 0
packages/app/src/components/server/server-row.tsx

@@ -0,0 +1,77 @@
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { serverDisplayName } from "@/context/server"
+import type { ServerHealth } from "@/utils/server-health"
+
+interface ServerRowProps extends ParentProps {
+  url: string
+  status?: ServerHealth
+  class?: string
+  nameClass?: string
+  versionClass?: string
+  dimmed?: boolean
+  badge?: JSXElement
+}
+
+export function ServerRow(props: ServerRowProps) {
+  const [truncated, setTruncated] = createSignal(false)
+  let nameRef: HTMLSpanElement | undefined
+  let versionRef: HTMLSpanElement | undefined
+
+  const check = () => {
+    const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
+    const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
+    setTruncated(nameTruncated || versionTruncated)
+  }
+
+  createEffect(() => {
+    props.url
+    props.status?.version
+    if (typeof requestAnimationFrame === "function") {
+      requestAnimationFrame(check)
+      return
+    }
+    check()
+  })
+
+  onMount(() => {
+    check()
+    if (typeof window === "undefined") return
+    window.addEventListener("resize", check)
+    onCleanup(() => window.removeEventListener("resize", check))
+  })
+
+  const tooltipValue = () => (
+    <span class="flex items-center gap-2">
+      <span>{serverDisplayName(props.url)}</span>
+      <Show when={props.status?.version}>
+        <span class="text-text-invert-base">{props.status?.version}</span>
+      </Show>
+    </span>
+  )
+
+  return (
+    <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
+      <div class={props.class} classList={{ "opacity-50": props.dimmed }}>
+        <div
+          classList={{
+            "size-1.5 rounded-full shrink-0": true,
+            "bg-icon-success-base": props.status?.healthy === true,
+            "bg-icon-critical-base": props.status?.healthy === false,
+            "bg-border-weak-base": props.status === undefined,
+          }}
+        />
+        <span ref={nameRef} class={props.nameClass ?? "truncate"}>
+          {serverDisplayName(props.url)}
+        </span>
+        <Show when={props.status?.version}>
+          <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
+            {props.status?.version}
+          </span>
+        </Show>
+        {props.badge}
+        {props.children}
+      </div>
+    </Tooltip>
+  )
+}

+ 39 - 88
packages/app/src/components/status-popover.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
 import { createStore, reconcile } from "solid-js/store"
 import { useNavigate } from "@solidjs/router"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
 import { Button } from "@opencode-ai/ui/button"
 import { Switch } from "@opencode-ai/ui/switch"
 import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { useSync } from "@/context/sync"
 import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
 import { usePlatform } from "@/context/platform"
 import { useLanguage } from "@/context/language"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { DialogSelectServer } from "./dialog-select-server"
 import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
-
-async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> {
-  const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
-  const sdk = createOpencodeClient({
-    baseUrl: url,
-    fetch: platform.fetch,
-    signal,
-  })
-  return sdk.global
-    .health()
-    .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
-    .catch(() => ({ healthy: false }))
-}
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
 
 export function StatusPopover() {
   const sync = useSync()
@@ -42,10 +27,11 @@ export function StatusPopover() {
   const navigate = useNavigate()
 
   const [store, setStore] = createStore({
-    status: {} as Record<string, ServerStatus | undefined>,
+    status: {} as Record<string, ServerHealth | undefined>,
     loading: null as string | null,
     defaultServerUrl: undefined as string | undefined,
   })
+  const fetcher = platform.fetch ?? globalThis.fetch
 
   const servers = createMemo(() => {
     const current = server.url
@@ -60,7 +46,7 @@ export function StatusPopover() {
     if (!list.length) return list
     const active = server.url
     const order = new Map(list.map((url, index) => [url, index] as const))
-    const rank = (value?: ServerStatus) => {
+    const rank = (value?: ServerHealth) => {
       if (value?.healthy === true) return 0
       if (value?.healthy === false) return 2
       return 1
@@ -75,10 +61,10 @@ export function StatusPopover() {
   })
 
   async function refreshHealth() {
-    const results: Record<string, ServerStatus> = {}
+    const results: Record<string, ServerHealth> = {}
     await Promise.all(
       servers().map(async (url) => {
-        results[url] = await checkHealth(url, platform)
+        results[url] = await checkServerHealth(url, fetcher)
       }),
     )
     setStore("status", reconcile(results))
@@ -213,78 +199,43 @@ export function StatusPopover() {
                     const isDefault = () => url === store.defaultServerUrl
                     const status = () => store.status[url]
                     const isBlocked = () => status()?.healthy === false
-                    const [truncated, setTruncated] = createSignal(false)
-                    let nameRef: HTMLSpanElement | undefined
-                    let versionRef: HTMLSpanElement | undefined
-
-                    onMount(() => {
-                      const check = () => {
-                        const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
-                        const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
-                        setTruncated(nameTruncated || versionTruncated)
-                      }
-                      check()
-                      window.addEventListener("resize", check)
-                      onCleanup(() => window.removeEventListener("resize", check))
-                    })
-
-                    const tooltipValue = () => {
-                      const name = serverDisplayName(url)
-                      const version = status()?.version
-                      return (
-                        <span class="flex items-center gap-2">
-                          <span>{name}</span>
-                          <Show when={version}>
-                            <span class="text-text-invert-base">{version}</span>
-                          </Show>
-                        </span>
-                      )
-                    }
 
                     return (
-                      <Tooltip value={tooltipValue()} placement="top" inactive={!truncated()}>
-                        <button
-                          type="button"
-                          class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
-                          classList={{
-                            "opacity-50": isBlocked(),
-                            "hover:bg-surface-raised-base-hover": !isBlocked(),
-                            "cursor-not-allowed": isBlocked(),
-                          }}
-                          aria-disabled={isBlocked()}
-                          onClick={() => {
-                            if (isBlocked()) return
-                            server.setActive(url)
-                            navigate("/")
-                          }}
+                      <button
+                        type="button"
+                        class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
+                        classList={{
+                          "hover:bg-surface-raised-base-hover": !isBlocked(),
+                          "cursor-not-allowed": isBlocked(),
+                        }}
+                        aria-disabled={isBlocked()}
+                        onClick={() => {
+                          if (isBlocked()) return
+                          server.setActive(url)
+                          navigate("/")
+                        }}
+                      >
+                        <ServerRow
+                          url={url}
+                          status={status()}
+                          dimmed={isBlocked()}
+                          class="flex items-center gap-2 w-full min-w-0"
+                          nameClass="text-14-regular text-text-base truncate"
+                          versionClass="text-12-regular text-text-weak truncate"
+                          badge={
+                            <Show when={isDefault()}>
+                              <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
+                                {language.t("common.default")}
+                              </span>
+                            </Show>
+                          }
                         >
-                          <div
-                            classList={{
-                              "size-1.5 rounded-full shrink-0": true,
-                              "bg-icon-success-base": status()?.healthy === true,
-                              "bg-icon-critical-base": status()?.healthy === false,
-                              "bg-border-weak-base": status() === undefined,
-                            }}
-                          />
-                          <span ref={nameRef} class="text-14-regular text-text-base truncate">
-                            {serverDisplayName(url)}
-                          </span>
-                          <Show when={status()?.version}>
-                            <span ref={versionRef} class="text-12-regular text-text-weak truncate">
-                              {status()?.version}
-                            </span>
-                          </Show>
-                          <Show when={isDefault()}>
-                            <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
-                              {language.t("common.default")}
-                            </span>
-                          </Show>
                           <div class="flex-1" />
                           <Show when={isActive()}>
                             <Icon name="check" size="small" class="text-icon-weak shrink-0" />
                           </Show>
-                        </button>
-                      </Tooltip>
+                        </ServerRow>
+                      </button>
                     )
                   }}
                 </For>

+ 10 - 13
packages/app/src/components/terminal.tsx

@@ -8,6 +8,7 @@ import { LocalPTY } from "@/context/terminal"
 import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
 import { useLanguage } from "@/context/language"
 import { showToast } from "@opencode-ai/ui/toast"
+import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
 
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
@@ -111,17 +112,13 @@ export const Terminal = (props: TerminalProps) => {
     const colors = getTerminalColors()
     setTerminalColors(colors)
     if (!term) return
-    const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
-    if (!setOption) return
-    setOption("theme", colors)
+    setOptionIfSupported(term, "theme", colors)
   })
 
   createEffect(() => {
     const font = monoFontFamily(settings.appearance.font())
     if (!term) return
-    const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
-    if (!setOption) return
-    setOption("fontFamily", font)
+    setOptionIfSupported(term, "fontFamily", font)
   })
 
   const focusTerminal = () => {
@@ -146,12 +143,12 @@ export const Terminal = (props: TerminalProps) => {
     const t = term
     if (!t) return
 
-    const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink
-    if (!link?.text) return
+    const text = getHoveredLinkText(t)
+    if (!text) return
 
     event.preventDefault()
     event.stopImmediatePropagation()
-    platform.openLink(link.text)
+    platform.openLink(text)
   }
 
   onMount(() => {
@@ -250,7 +247,7 @@ export const Terminal = (props: TerminalProps) => {
 
       const fit = new mod.FitAddon()
       const serializer = new SerializeAddon()
-      cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(fit))
       t.loadAddon(serializer)
       t.loadAddon(fit)
       fitAddon = fit
@@ -303,19 +300,19 @@ export const Terminal = (props: TerminalProps) => {
             .catch(() => {})
         }
       })
-      cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(onResize))
       const onData = t.onData((data) => {
         if (socket.readyState === WebSocket.OPEN) {
           socket.send(data)
         }
       })
-      cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(onData))
       const onKey = t.onKey((key) => {
         if (key.key == "Enter") {
           props.onSubmit?.()
         }
       })
-      cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
+      cleanups.push(() => disposeIfDisposable(onKey))
       // t.onScroll((ydisp) => {
       // console.log("Scroll position:", ydisp)
       // })

+ 43 - 0
packages/app/src/context/command-keybind.test.ts

@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { formatKeybind, matchKeybind, parseKeybind } from "./command"
+
+describe("command keybind helpers", () => {
+  test("parseKeybind handles aliases and multiple combos", () => {
+    const keybinds = parseKeybind("control+option+k, mod+shift+comma")
+
+    expect(keybinds).toHaveLength(2)
+    expect(keybinds[0]).toEqual({
+      key: "k",
+      ctrl: true,
+      meta: false,
+      shift: false,
+      alt: true,
+    })
+    expect(keybinds[1]?.shift).toBe(true)
+    expect(keybinds[1]?.key).toBe("comma")
+    expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
+  })
+
+  test("parseKeybind treats none and empty as disabled", () => {
+    expect(parseKeybind("none")).toEqual([])
+    expect(parseKeybind("")).toEqual([])
+  })
+
+  test("matchKeybind normalizes punctuation keys", () => {
+    const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
+
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
+    expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
+  })
+
+  test("formatKeybind returns human readable output", () => {
+    const display = formatKeybind("ctrl+alt+arrowup")
+
+    expect(display).toContain("↑")
+    expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
+    expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
+    expect(formatKeybind("none")).toBe("")
+  })
+})

+ 10 - 30
packages/app/src/context/file-content-eviction-accounting.test.ts

@@ -1,33 +1,13 @@
-import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"
-
-let evictContentLru: (keep: Set<string> | undefined, evict: (path: string) => void) => void
-let getFileContentBytesTotal: () => number
-let getFileContentEntryCount: () => number
-let removeFileContentBytes: (path: string) => void
-let resetFileContentLru: () => void
-let setFileContentBytes: (path: string, bytes: number) => void
-let touchFileContent: (path: string, bytes?: number) => void
-
-beforeAll(async () => {
-  mock.module("@solidjs/router", () => ({
-    useParams: () => ({}),
-  }))
-  mock.module("@opencode-ai/ui/context", () => ({
-    createSimpleContext: () => ({
-      use: () => undefined,
-      provider: () => undefined,
-    }),
-  }))
-
-  const mod = await import("./file")
-  evictContentLru = mod.evictContentLru
-  getFileContentBytesTotal = mod.getFileContentBytesTotal
-  getFileContentEntryCount = mod.getFileContentEntryCount
-  removeFileContentBytes = mod.removeFileContentBytes
-  resetFileContentLru = mod.resetFileContentLru
-  setFileContentBytes = mod.setFileContentBytes
-  touchFileContent = mod.touchFileContent
-})
+import { afterEach, describe, expect, test } from "bun:test"
+import {
+  evictContentLru,
+  getFileContentBytesTotal,
+  getFileContentEntryCount,
+  removeFileContentBytes,
+  resetFileContentLru,
+  setFileContentBytes,
+  touchFileContent,
+} from "./file/content-cache"
 
 describe("file content eviction accounting", () => {
   afterEach(() => {

+ 114 - 602
packages/app/src/context/file.tsx

@@ -1,324 +1,45 @@
-import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
 import { showToast } from "@opencode-ai/ui/toast"
 import { useParams } from "@solidjs/router"
 import { getFilename } from "@opencode-ai/util/path"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
 import { useLanguage } from "@/context/language"
-import { Persist, persisted } from "@/utils/persist"
-import { createScopedCache } from "@/utils/scoped-cache"
-
-export type FileSelection = {
-  startLine: number
-  startChar: number
-  endLine: number
-  endChar: number
-}
-
-export type SelectedLineRange = {
-  start: number
-  end: number
-  side?: "additions" | "deletions"
-  endSide?: "additions" | "deletions"
-}
-
-export type FileViewState = {
-  scrollTop?: number
-  scrollLeft?: number
-  selectedLines?: SelectedLineRange | null
-}
-
-export type FileState = {
-  path: string
-  name: string
-  loaded?: boolean
-  loading?: boolean
-  error?: string
-  content?: FileContent
-}
-
-type DirectoryState = {
-  expanded: boolean
-  loaded?: boolean
-  loading?: boolean
-  error?: string
-  children?: string[]
-}
-
-function stripFileProtocol(input: string) {
-  if (!input.startsWith("file://")) return input
-  return input.slice("file://".length)
-}
-
-function stripQueryAndHash(input: string) {
-  const hashIndex = input.indexOf("#")
-  const queryIndex = input.indexOf("?")
-
-  if (hashIndex !== -1 && queryIndex !== -1) {
-    return input.slice(0, Math.min(hashIndex, queryIndex))
-  }
-
-  if (hashIndex !== -1) return input.slice(0, hashIndex)
-  if (queryIndex !== -1) return input.slice(0, queryIndex)
-  return input
-}
-
-function unquoteGitPath(input: string) {
-  if (!input.startsWith('"')) return input
-  if (!input.endsWith('"')) return input
-  const body = input.slice(1, -1)
-  const bytes: number[] = []
-
-  for (let i = 0; i < body.length; i++) {
-    const char = body[i]!
-    if (char !== "\\") {
-      bytes.push(char.charCodeAt(0))
-      continue
-    }
-
-    const next = body[i + 1]
-    if (!next) {
-      bytes.push("\\".charCodeAt(0))
-      continue
-    }
-
-    if (next >= "0" && next <= "7") {
-      const chunk = body.slice(i + 1, i + 4)
-      const match = chunk.match(/^[0-7]{1,3}/)
-      if (!match) {
-        bytes.push(next.charCodeAt(0))
-        i++
-        continue
-      }
-      bytes.push(parseInt(match[0], 8))
-      i += match[0].length
-      continue
-    }
-
-    const escaped =
-      next === "n"
-        ? "\n"
-        : next === "r"
-          ? "\r"
-          : next === "t"
-            ? "\t"
-            : next === "b"
-              ? "\b"
-              : next === "f"
-                ? "\f"
-                : next === "v"
-                  ? "\v"
-                  : next === "\\" || next === '"'
-                    ? next
-                    : undefined
-
-    bytes.push((escaped ?? next).charCodeAt(0))
-    i++
-  }
-
-  return new TextDecoder().decode(new Uint8Array(bytes))
-}
-
-export function selectionFromLines(range: SelectedLineRange): FileSelection {
-  const startLine = Math.min(range.start, range.end)
-  const endLine = Math.max(range.start, range.end)
-  return {
-    startLine,
-    endLine,
-    startChar: 0,
-    endChar: 0,
-  }
-}
-
-function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
-  if (range.start <= range.end) return range
-
-  const startSide = range.side
-  const endSide = range.endSide ?? startSide
-
-  return {
-    ...range,
-    start: range.end,
-    end: range.start,
-    side: endSide,
-    endSide: startSide !== endSide ? startSide : undefined,
-  }
-}
-
-const WORKSPACE_KEY = "__workspace__"
-const MAX_FILE_VIEW_SESSIONS = 20
-const MAX_VIEW_FILES = 500
-
-const MAX_FILE_CONTENT_ENTRIES = 40
-const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
-
-const contentLru = new Map<string, number>()
-let contentBytesTotal = 0
-
-function approxBytes(content: FileContent) {
-  const patchBytes =
-    content.patch?.hunks.reduce((total, hunk) => {
-      return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
-    }, 0) ?? 0
-
-  return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
-}
-
-function setContentBytes(path: string, nextBytes: number) {
-  const prev = contentLru.get(path)
-  if (prev !== undefined) contentBytesTotal -= prev
-  contentLru.delete(path)
-  contentLru.set(path, nextBytes)
-  contentBytesTotal += nextBytes
-}
-
-function touchContent(path: string, bytes?: number) {
-  const prev = contentLru.get(path)
-  if (prev === undefined && bytes === undefined) return
-  setContentBytes(path, bytes ?? prev ?? 0)
-}
-
-function removeContentBytes(path: string) {
-  const prev = contentLru.get(path)
-  if (prev === undefined) return
-  contentLru.delete(path)
-  contentBytesTotal -= prev
-}
-
-function resetContentBytes() {
-  contentLru.clear()
-  contentBytesTotal = 0
-}
-
-export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
-  const protectedSet = keep ?? new Set<string>()
-
-  while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || contentBytesTotal > MAX_FILE_CONTENT_BYTES) {
-    const path = contentLru.keys().next().value
-    if (!path) return
-
-    if (protectedSet.has(path)) {
-      touchContent(path)
-      if (contentLru.size <= protectedSet.size) return
-      continue
-    }
-
-    removeContentBytes(path)
-    evict(path)
-  }
-}
-
-export function resetFileContentLru() {
-  resetContentBytes()
-}
-
-export function setFileContentBytes(path: string, bytes: number) {
-  setContentBytes(path, bytes)
-}
-
-export function removeFileContentBytes(path: string) {
-  removeContentBytes(path)
-}
-
-export function touchFileContent(path: string, bytes?: number) {
-  touchContent(path, bytes)
-}
-
-export function getFileContentBytesTotal() {
-  return contentBytesTotal
-}
-
-export function getFileContentEntryCount() {
-  return contentLru.size
-}
-
-function createViewSession(dir: string, id: string | undefined) {
-  const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
-
-  const [view, setView, _, ready] = persisted(
-    Persist.scoped(dir, id, "file-view", [legacyViewKey]),
-    createStore<{
-      file: Record<string, FileViewState>
-    }>({
-      file: {},
-    }),
-  )
-
-  const meta = { pruned: false }
-
-  const pruneView = (keep?: string) => {
-    const keys = Object.keys(view.file)
-    if (keys.length <= MAX_VIEW_FILES) return
-
-    const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
-    if (drop.length === 0) return
-
-    setView(
-      produce((draft) => {
-        for (const key of drop) {
-          delete draft.file[key]
-        }
-      }),
-    )
-  }
-
-  createEffect(() => {
-    if (!ready()) return
-    if (meta.pruned) return
-    meta.pruned = true
-    pruneView()
-  })
-
-  const scrollTop = (path: string) => view.file[path]?.scrollTop
-  const scrollLeft = (path: string) => view.file[path]?.scrollLeft
-  const selectedLines = (path: string) => view.file[path]?.selectedLines
-
-  const setScrollTop = (path: string, top: number) => {
-    setView("file", path, (current) => {
-      if (current?.scrollTop === top) return current
-      return {
-        ...(current ?? {}),
-        scrollTop: top,
-      }
-    })
-    pruneView(path)
-  }
-
-  const setScrollLeft = (path: string, left: number) => {
-    setView("file", path, (current) => {
-      if (current?.scrollLeft === left) return current
-      return {
-        ...(current ?? {}),
-        scrollLeft: left,
-      }
-    })
-    pruneView(path)
-  }
-
-  const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
-    const next = range ? normalizeSelectedLines(range) : null
-    setView("file", path, (current) => {
-      if (current?.selectedLines === next) return current
-      return {
-        ...(current ?? {}),
-        selectedLines: next,
-      }
-    })
-    pruneView(path)
-  }
-
-  return {
-    ready,
-    scrollTop,
-    scrollLeft,
-    selectedLines,
-    setScrollTop,
-    setScrollLeft,
-    setSelectedLines,
-  }
+import { createPathHelpers } from "./file/path"
+import {
+  approxBytes,
+  evictContentLru,
+  getFileContentBytesTotal,
+  getFileContentEntryCount,
+  hasFileContent,
+  removeFileContentBytes,
+  resetFileContentLru,
+  setFileContentBytes,
+  touchFileContent,
+} from "./file/content-cache"
+import { createFileViewCache } from "./file/view-cache"
+import { createFileTreeStore } from "./file/tree-store"
+import { invalidateFromWatcher } from "./file/watcher"
+import {
+  selectionFromLines,
+  type FileState,
+  type FileSelection,
+  type FileViewState,
+  type SelectedLineRange,
+} from "./file/types"
+
+export type { FileSelection, SelectedLineRange, FileViewState, FileState }
+export { selectionFromLines }
+export {
+  evictContentLru,
+  getFileContentBytesTotal,
+  getFileContentEntryCount,
+  removeFileContentBytes,
+  resetFileContentLru,
+  setFileContentBytes,
+  touchFileContent,
 }
 
 export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -326,76 +47,39 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
   gate: false,
   init: () => {
     const sdk = useSDK()
-    const sync = useSync()
+    useSync()
     const params = useParams()
     const language = useLanguage()
 
     const scope = createMemo(() => sdk.directory)
-
-    function normalize(input: string) {
-      const root = scope()
-      const prefix = root.endsWith("/") ? root : root + "/"
-
-      let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
-
-      if (path.startsWith(prefix)) {
-        path = path.slice(prefix.length)
-      }
-
-      if (path.startsWith(root)) {
-        path = path.slice(root.length)
-      }
-
-      if (path.startsWith("./")) {
-        path = path.slice(2)
-      }
-
-      if (path.startsWith("/")) {
-        path = path.slice(1)
-      }
-
-      return path
-    }
-
-    function tab(input: string) {
-      const path = normalize(input)
-      return `file://${path}`
-    }
-
-    function pathFromTab(tabValue: string) {
-      if (!tabValue.startsWith("file://")) return
-      return normalize(tabValue)
-    }
+    const path = createPathHelpers(scope)
 
     const inflight = new Map<string, Promise<void>>()
-    const treeInflight = new Map<string, Promise<void>>()
-
-    const search = (query: string, dirs: "true" | "false") =>
-      sdk.client.find.files({ query, dirs }).then(
-        (x) => (x.data ?? []).map(normalize),
-        () => [],
-      )
-
     const [store, setStore] = createStore<{
       file: Record<string, FileState>
     }>({
       file: {},
     })
 
-    const [tree, setTree] = createStore<{
-      node: Record<string, FileNode>
-      dir: Record<string, DirectoryState>
-    }>({
-      node: {},
-      dir: { "": { expanded: true } },
+    const tree = createFileTreeStore({
+      scope,
+      normalizeDir: path.normalizeDir,
+      list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
+      onError: (message) => {
+        showToast({
+          variant: "error",
+          title: language.t("toast.file.listFailed.title"),
+          description: message,
+        })
+      },
     })
 
     const evictContent = (keep?: Set<string>) => {
-      evictContentLru(keep, (path) => {
-        if (!store.file[path]) return
+      evictContentLru(keep, (target) => {
+        if (!store.file[target]) return
         setStore(
           "file",
-          path,
+          target,
           produce((draft) => {
             draft.content = undefined
             draft.loaded = false
@@ -407,57 +91,31 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
     createEffect(() => {
       scope()
       inflight.clear()
-      treeInflight.clear()
-      resetContentBytes()
-
+      resetFileContentLru()
       batch(() => {
         setStore("file", reconcile({}))
-        setTree("node", reconcile({}))
-        setTree("dir", reconcile({}))
-        setTree("dir", "", { expanded: true })
+        tree.reset()
       })
     })
 
-    const viewCache = createScopedCache(
-      (key) => {
-        const split = key.lastIndexOf("\n")
-        const dir = split >= 0 ? key.slice(0, split) : key
-        const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
-        return createRoot((dispose) => ({
-          value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
-          dispose,
-        }))
-      },
-      {
-        maxEntries: MAX_FILE_VIEW_SESSIONS,
-        dispose: (entry) => entry.dispose(),
-      },
-    )
-
-    const loadView = (dir: string, id: string | undefined) => {
-      const key = `${dir}\n${id ?? WORKSPACE_KEY}`
-      return viewCache.get(key).value
-    }
-
-    const view = createMemo(() => loadView(scope(), params.id))
+    const viewCache = createFileViewCache()
+    const view = createMemo(() => viewCache.load(scope(), params.id))
 
-    function ensure(path: string) {
-      if (!path) return
-      if (store.file[path]) return
-      setStore("file", path, { path, name: getFilename(path) })
+    const ensure = (file: string) => {
+      if (!file) return
+      if (store.file[file]) return
+      setStore("file", file, { path: file, name: getFilename(file) })
     }
 
-    function load(input: string, options?: { force?: boolean }) {
-      const path = normalize(input)
-      if (!path) return Promise.resolve()
+    const load = (input: string, options?: { force?: boolean }) => {
+      const file = path.normalize(input)
+      if (!file) return Promise.resolve()
 
       const directory = scope()
-      const key = `${directory}\n${path}`
-      const client = sdk.client
-
-      ensure(path)
+      const key = `${directory}\n${file}`
+      ensure(file)
 
-      const current = store.file[path]
+      const current = store.file[file]
       if (!options?.force && current?.loaded) return Promise.resolve()
 
       const pending = inflight.get(key)
@@ -465,21 +123,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
 
       setStore(
         "file",
-        path,
+        file,
         produce((draft) => {
           draft.loading = true
           draft.error = undefined
         }),
       )
 
-      const promise = client.file
-        .read({ path })
+      const promise = sdk.client.file
+        .read({ path: file })
         .then((x) => {
           if (scope() !== directory) return
           const content = x.data
           setStore(
             "file",
-            path,
+            file,
             produce((draft) => {
               draft.loaded = true
               draft.loading = false
@@ -488,14 +146,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
           )
 
           if (!content) return
-          touchContent(path, approxBytes(content))
-          evictContent(new Set([path]))
+          touchFileContent(file, approxBytes(content))
+          evictContent(new Set([file]))
         })
         .catch((e) => {
           if (scope() !== directory) return
           setStore(
             "file",
-            path,
+            file,
             produce((draft) => {
               draft.loading = false
               draft.error = e.message
@@ -515,200 +173,54 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
       return promise
     }
 
-    function normalizeDir(input: string) {
-      return normalize(input).replace(/\/+$/, "")
-    }
-
-    function ensureDir(path: string) {
-      if (tree.dir[path]) return
-      setTree("dir", path, { expanded: false })
-    }
-
-    function listDir(input: string, options?: { force?: boolean }) {
-      const dir = normalizeDir(input)
-      ensureDir(dir)
-
-      const current = tree.dir[dir]
-      if (!options?.force && current?.loaded) return Promise.resolve()
-
-      const pending = treeInflight.get(dir)
-      if (pending) return pending
-
-      setTree(
-        "dir",
-        dir,
-        produce((draft) => {
-          draft.loading = true
-          draft.error = undefined
-        }),
+    const search = (query: string, dirs: "true" | "false") =>
+      sdk.client.find.files({ query, dirs }).then(
+        (x) => (x.data ?? []).map(path.normalize),
+        () => [],
       )
 
-      const directory = scope()
-
-      const promise = sdk.client.file
-        .list({ path: dir })
-        .then((x) => {
-          if (scope() !== directory) return
-          const nodes = x.data ?? []
-          const prevChildren = tree.dir[dir]?.children ?? []
-          const nextChildren = nodes.map((node) => node.path)
-          const nextSet = new Set(nextChildren)
-
-          setTree(
-            "node",
-            produce((draft) => {
-              const removedDirs: string[] = []
-
-              for (const child of prevChildren) {
-                if (nextSet.has(child)) continue
-                const existing = draft[child]
-                if (existing?.type === "directory") removedDirs.push(child)
-                delete draft[child]
-              }
-
-              if (removedDirs.length > 0) {
-                const keys = Object.keys(draft)
-                for (const key of keys) {
-                  for (const removed of removedDirs) {
-                    if (!key.startsWith(removed + "/")) continue
-                    delete draft[key]
-                    break
-                  }
-                }
-              }
-
-              for (const node of nodes) {
-                draft[node.path] = node
-              }
-            }),
-          )
-
-          setTree(
-            "dir",
-            dir,
-            produce((draft) => {
-              draft.loaded = true
-              draft.loading = false
-              draft.children = nextChildren
-            }),
-          )
-        })
-        .catch((e) => {
-          if (scope() !== directory) return
-          setTree(
-            "dir",
-            dir,
-            produce((draft) => {
-              draft.loading = false
-              draft.error = e.message
-            }),
-          )
-          showToast({
-            variant: "error",
-            title: language.t("toast.file.listFailed.title"),
-            description: e.message,
-          })
-        })
-        .finally(() => {
-          treeInflight.delete(dir)
-        })
-
-      treeInflight.set(dir, promise)
-      return promise
-    }
-
-    function expandDir(input: string) {
-      const dir = normalizeDir(input)
-      ensureDir(dir)
-      setTree("dir", dir, "expanded", true)
-      void listDir(dir)
-    }
-
-    function collapseDir(input: string) {
-      const dir = normalizeDir(input)
-      ensureDir(dir)
-      setTree("dir", dir, "expanded", false)
-    }
-
-    function dirState(input: string) {
-      const dir = normalizeDir(input)
-      return tree.dir[dir]
-    }
-
-    function children(input: string) {
-      const dir = normalizeDir(input)
-      const ids = tree.dir[dir]?.children
-      if (!ids) return []
-      const out: FileNode[] = []
-      for (const id of ids) {
-        const node = tree.node[id]
-        if (node) out.push(node)
-      }
-      return out
-    }
-
     const stop = sdk.event.listen((e) => {
-      const event = e.details
-      if (event.type !== "file.watcher.updated") return
-      const path = normalize(event.properties.file)
-      if (!path) return
-      if (path.startsWith(".git/")) return
-
-      if (store.file[path]) {
-        load(path, { force: true })
-      }
-
-      const kind = event.properties.event
-      if (kind === "change") {
-        const dir = (() => {
-          if (path === "") return ""
-          const node = tree.node[path]
-          if (node?.type !== "directory") return
-          return path
-        })()
-        if (dir === undefined) return
-        if (!tree.dir[dir]?.loaded) return
-        listDir(dir, { force: true })
-        return
-      }
-      if (kind !== "add" && kind !== "unlink") return
-
-      const parent = path.split("/").slice(0, -1).join("/")
-      if (!tree.dir[parent]?.loaded) return
-
-      listDir(parent, { force: true })
+      invalidateFromWatcher(e.details, {
+        normalize: path.normalize,
+        hasFile: (file) => Boolean(store.file[file]),
+        loadFile: (file) => {
+          void load(file, { force: true })
+        },
+        node: tree.node,
+        isDirLoaded: tree.isLoaded,
+        refreshDir: (dir) => {
+          void tree.listDir(dir, { force: true })
+        },
+      })
     })
 
     const get = (input: string) => {
-      const path = normalize(input)
-      const file = store.file[path]
-      const content = file?.content
-      if (!content) return file
-      if (contentLru.has(path)) {
-        touchContent(path)
-        return file
+      const file = path.normalize(input)
+      const state = store.file[file]
+      const content = state?.content
+      if (!content) return state
+      if (hasFileContent(file)) {
+        touchFileContent(file)
+        return state
       }
-      touchContent(path, approxBytes(content))
-      return file
+      touchFileContent(file, approxBytes(content))
+      return state
     }
 
-    const scrollTop = (input: string) => view().scrollTop(normalize(input))
-    const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
-    const selectedLines = (input: string) => view().selectedLines(normalize(input))
+    const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
+    const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
+    const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
 
     const setScrollTop = (input: string, top: number) => {
-      const path = normalize(input)
-      view().setScrollTop(path, top)
+      view().setScrollTop(path.normalize(input), top)
     }
 
     const setScrollLeft = (input: string, left: number) => {
-      const path = normalize(input)
-      view().setScrollLeft(path, left)
+      view().setScrollLeft(path.normalize(input), left)
     }
 
     const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
-      const path = normalize(input)
-      view().setSelectedLines(path, range)
+      view().setSelectedLines(path.normalize(input), range)
     }
 
     onCleanup(() => {
@@ -718,22 +230,22 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
 
     return {
       ready: () => view().ready(),
-      normalize,
-      tab,
-      pathFromTab,
+      normalize: path.normalize,
+      tab: path.tab,
+      pathFromTab: path.pathFromTab,
       tree: {
-        list: listDir,
-        refresh: (input: string) => listDir(input, { force: true }),
-        state: dirState,
-        children,
-        expand: expandDir,
-        collapse: collapseDir,
+        list: tree.listDir,
+        refresh: (input: string) => tree.listDir(input, { force: true }),
+        state: tree.dirState,
+        children: tree.children,
+        expand: tree.expandDir,
+        collapse: tree.collapseDir,
         toggle(input: string) {
-          if (dirState(input)?.expanded) {
-            collapseDir(input)
+          if (tree.dirState(input)?.expanded) {
+            tree.collapseDir(input)
             return
           }
-          expandDir(input)
+          tree.expandDir(input)
         },
       },
       get,

+ 88 - 0
packages/app/src/context/file/content-cache.ts

@@ -0,0 +1,88 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+const MAX_FILE_CONTENT_ENTRIES = 40
+const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+
+const lru = new Map<string, number>()
+let total = 0
+
+export function approxBytes(content: FileContent) {
+  const patchBytes =
+    content.patch?.hunks.reduce((sum, hunk) => {
+      return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
+    }, 0) ?? 0
+
+  return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
+}
+
+function setBytes(path: string, nextBytes: number) {
+  const prev = lru.get(path)
+  if (prev !== undefined) total -= prev
+  lru.delete(path)
+  lru.set(path, nextBytes)
+  total += nextBytes
+}
+
+function touch(path: string, bytes?: number) {
+  const prev = lru.get(path)
+  if (prev === undefined && bytes === undefined) return
+  setBytes(path, bytes ?? prev ?? 0)
+}
+
+function remove(path: string) {
+  const prev = lru.get(path)
+  if (prev === undefined) return
+  lru.delete(path)
+  total -= prev
+}
+
+function reset() {
+  lru.clear()
+  total = 0
+}
+
+export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
+  const set = keep ?? new Set<string>()
+
+  while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
+    const path = lru.keys().next().value
+    if (!path) return
+
+    if (set.has(path)) {
+      touch(path)
+      if (lru.size <= set.size) return
+      continue
+    }
+
+    remove(path)
+    evict(path)
+  }
+}
+
+export function resetFileContentLru() {
+  reset()
+}
+
+export function setFileContentBytes(path: string, bytes: number) {
+  setBytes(path, bytes)
+}
+
+export function removeFileContentBytes(path: string) {
+  remove(path)
+}
+
+export function touchFileContent(path: string, bytes?: number) {
+  touch(path, bytes)
+}
+
+export function getFileContentBytesTotal() {
+  return total
+}
+
+export function getFileContentEntryCount() {
+  return lru.size
+}
+
+export function hasFileContent(path: string) {
+  return lru.has(path)
+}

+ 27 - 0
packages/app/src/context/file/path.test.ts

@@ -0,0 +1,27 @@
+import { describe, expect, test } from "bun:test"
+import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path"
+
+describe("file path helpers", () => {
+  test("normalizes file inputs against workspace root", () => {
+    const path = createPathHelpers(() => "/repo")
+    expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
+    expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
+    expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
+    expect(path.normalizeDir("src/components///")).toBe("src/components")
+    expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
+    expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
+    expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
+  })
+
+  test("keeps query/hash stripping behavior stable", () => {
+    expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
+    expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
+    expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
+  })
+
+  test("unquotes git escaped octal path strings", () => {
+    expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
+    expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
+    expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
+  })
+})

+ 119 - 0
packages/app/src/context/file/path.ts

@@ -0,0 +1,119 @@
+export function stripFileProtocol(input: string) {
+  if (!input.startsWith("file://")) return input
+  return input.slice("file://".length)
+}
+
+export function stripQueryAndHash(input: string) {
+  const hashIndex = input.indexOf("#")
+  const queryIndex = input.indexOf("?")
+
+  if (hashIndex !== -1 && queryIndex !== -1) {
+    return input.slice(0, Math.min(hashIndex, queryIndex))
+  }
+
+  if (hashIndex !== -1) return input.slice(0, hashIndex)
+  if (queryIndex !== -1) return input.slice(0, queryIndex)
+  return input
+}
+
+export function unquoteGitPath(input: string) {
+  if (!input.startsWith('"')) return input
+  if (!input.endsWith('"')) return input
+  const body = input.slice(1, -1)
+  const bytes: number[] = []
+
+  for (let i = 0; i < body.length; i++) {
+    const char = body[i]!
+    if (char !== "\\") {
+      bytes.push(char.charCodeAt(0))
+      continue
+    }
+
+    const next = body[i + 1]
+    if (!next) {
+      bytes.push("\\".charCodeAt(0))
+      continue
+    }
+
+    if (next >= "0" && next <= "7") {
+      const chunk = body.slice(i + 1, i + 4)
+      const match = chunk.match(/^[0-7]{1,3}/)
+      if (!match) {
+        bytes.push(next.charCodeAt(0))
+        i++
+        continue
+      }
+      bytes.push(parseInt(match[0], 8))
+      i += match[0].length
+      continue
+    }
+
+    const escaped =
+      next === "n"
+        ? "\n"
+        : next === "r"
+          ? "\r"
+          : next === "t"
+            ? "\t"
+            : next === "b"
+              ? "\b"
+              : next === "f"
+                ? "\f"
+                : next === "v"
+                  ? "\v"
+                  : next === "\\" || next === '"'
+                    ? next
+                    : undefined
+
+    bytes.push((escaped ?? next).charCodeAt(0))
+    i++
+  }
+
+  return new TextDecoder().decode(new Uint8Array(bytes))
+}
+
+export function createPathHelpers(scope: () => string) {
+  const normalize = (input: string) => {
+    const root = scope()
+    const prefix = root.endsWith("/") ? root : root + "/"
+
+    let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
+
+    if (path.startsWith(prefix)) {
+      path = path.slice(prefix.length)
+    }
+
+    if (path.startsWith(root)) {
+      path = path.slice(root.length)
+    }
+
+    if (path.startsWith("./")) {
+      path = path.slice(2)
+    }
+
+    if (path.startsWith("/")) {
+      path = path.slice(1)
+    }
+
+    return path
+  }
+
+  const tab = (input: string) => {
+    const path = normalize(input)
+    return `file://${path}`
+  }
+
+  const pathFromTab = (tabValue: string) => {
+    if (!tabValue.startsWith("file://")) return
+    return normalize(tabValue)
+  }
+
+  const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
+
+  return {
+    normalize,
+    tab,
+    pathFromTab,
+    normalizeDir,
+  }
+}

+ 170 - 0
packages/app/src/context/file/tree-store.ts

@@ -0,0 +1,170 @@
+import { createStore, produce, reconcile } from "solid-js/store"
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type DirectoryState = {
+  expanded: boolean
+  loaded?: boolean
+  loading?: boolean
+  error?: string
+  children?: string[]
+}
+
+type TreeStoreOptions = {
+  scope: () => string
+  normalizeDir: (input: string) => string
+  list: (input: string) => Promise<FileNode[]>
+  onError: (message: string) => void
+}
+
+export function createFileTreeStore(options: TreeStoreOptions) {
+  const [tree, setTree] = createStore<{
+    node: Record<string, FileNode>
+    dir: Record<string, DirectoryState>
+  }>({
+    node: {},
+    dir: { "": { expanded: true } },
+  })
+
+  const inflight = new Map<string, Promise<void>>()
+
+  const reset = () => {
+    inflight.clear()
+    setTree("node", reconcile({}))
+    setTree("dir", reconcile({}))
+    setTree("dir", "", { expanded: true })
+  }
+
+  const ensureDir = (path: string) => {
+    if (tree.dir[path]) return
+    setTree("dir", path, { expanded: false })
+  }
+
+  const listDir = (input: string, opts?: { force?: boolean }) => {
+    const dir = options.normalizeDir(input)
+    ensureDir(dir)
+
+    const current = tree.dir[dir]
+    if (!opts?.force && current?.loaded) return Promise.resolve()
+
+    const pending = inflight.get(dir)
+    if (pending) return pending
+
+    setTree(
+      "dir",
+      dir,
+      produce((draft) => {
+        draft.loading = true
+        draft.error = undefined
+      }),
+    )
+
+    const directory = options.scope()
+
+    const promise = options
+      .list(dir)
+      .then((nodes) => {
+        if (options.scope() !== directory) return
+        const prevChildren = tree.dir[dir]?.children ?? []
+        const nextChildren = nodes.map((node) => node.path)
+        const nextSet = new Set(nextChildren)
+
+        setTree(
+          "node",
+          produce((draft) => {
+            const removedDirs: string[] = []
+
+            for (const child of prevChildren) {
+              if (nextSet.has(child)) continue
+              const existing = draft[child]
+              if (existing?.type === "directory") removedDirs.push(child)
+              delete draft[child]
+            }
+
+            if (removedDirs.length > 0) {
+              const keys = Object.keys(draft)
+              for (const key of keys) {
+                for (const removed of removedDirs) {
+                  if (!key.startsWith(removed + "/")) continue
+                  delete draft[key]
+                  break
+                }
+              }
+            }
+
+            for (const node of nodes) {
+              draft[node.path] = node
+            }
+          }),
+        )
+
+        setTree(
+          "dir",
+          dir,
+          produce((draft) => {
+            draft.loaded = true
+            draft.loading = false
+            draft.children = nextChildren
+          }),
+        )
+      })
+      .catch((e) => {
+        if (options.scope() !== directory) return
+        setTree(
+          "dir",
+          dir,
+          produce((draft) => {
+            draft.loading = false
+            draft.error = e.message
+          }),
+        )
+        options.onError(e.message)
+      })
+      .finally(() => {
+        inflight.delete(dir)
+      })
+
+    inflight.set(dir, promise)
+    return promise
+  }
+
+  const expandDir = (input: string) => {
+    const dir = options.normalizeDir(input)
+    ensureDir(dir)
+    setTree("dir", dir, "expanded", true)
+    void listDir(dir)
+  }
+
+  const collapseDir = (input: string) => {
+    const dir = options.normalizeDir(input)
+    ensureDir(dir)
+    setTree("dir", dir, "expanded", false)
+  }
+
+  const dirState = (input: string) => {
+    const dir = options.normalizeDir(input)
+    return tree.dir[dir]
+  }
+
+  const children = (input: string) => {
+    const dir = options.normalizeDir(input)
+    const ids = tree.dir[dir]?.children
+    if (!ids) return []
+    const out: FileNode[] = []
+    for (const id of ids) {
+      const node = tree.node[id]
+      if (node) out.push(node)
+    }
+    return out
+  }
+
+  return {
+    listDir,
+    expandDir,
+    collapseDir,
+    dirState,
+    children,
+    node: (path: string) => tree.node[path],
+    isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
+    reset,
+  }
+}

+ 41 - 0
packages/app/src/context/file/types.ts

@@ -0,0 +1,41 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type FileSelection = {
+  startLine: number
+  startChar: number
+  endLine: number
+  endChar: number
+}
+
+export type SelectedLineRange = {
+  start: number
+  end: number
+  side?: "additions" | "deletions"
+  endSide?: "additions" | "deletions"
+}
+
+export type FileViewState = {
+  scrollTop?: number
+  scrollLeft?: number
+  selectedLines?: SelectedLineRange | null
+}
+
+export type FileState = {
+  path: string
+  name: string
+  loaded?: boolean
+  loading?: boolean
+  error?: string
+  content?: FileContent
+}
+
+export function selectionFromLines(range: SelectedLineRange): FileSelection {
+  const startLine = Math.min(range.start, range.end)
+  const endLine = Math.max(range.start, range.end)
+  return {
+    startLine,
+    endLine,
+    startChar: 0,
+    endChar: 0,
+  }
+}

+ 136 - 0
packages/app/src/context/file/view-cache.ts

@@ -0,0 +1,136 @@
+import { createEffect, createRoot } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
+import type { FileViewState, SelectedLineRange } from "./types"
+
+const WORKSPACE_KEY = "__workspace__"
+const MAX_FILE_VIEW_SESSIONS = 20
+const MAX_VIEW_FILES = 500
+
+function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
+  if (range.start <= range.end) return range
+
+  const startSide = range.side
+  const endSide = range.endSide ?? startSide
+
+  return {
+    ...range,
+    start: range.end,
+    end: range.start,
+    side: endSide,
+    endSide: startSide !== endSide ? startSide : undefined,
+  }
+}
+
+function createViewSession(dir: string, id: string | undefined) {
+  const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
+
+  const [view, setView, _, ready] = persisted(
+    Persist.scoped(dir, id, "file-view", [legacyViewKey]),
+    createStore<{
+      file: Record<string, FileViewState>
+    }>({
+      file: {},
+    }),
+  )
+
+  const meta = { pruned: false }
+
+  const pruneView = (keep?: string) => {
+    const keys = Object.keys(view.file)
+    if (keys.length <= MAX_VIEW_FILES) return
+
+    const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+    if (drop.length === 0) return
+
+    setView(
+      produce((draft) => {
+        for (const key of drop) {
+          delete draft.file[key]
+        }
+      }),
+    )
+  }
+
+  createEffect(() => {
+    if (!ready()) return
+    if (meta.pruned) return
+    meta.pruned = true
+    pruneView()
+  })
+
+  const scrollTop = (path: string) => view.file[path]?.scrollTop
+  const scrollLeft = (path: string) => view.file[path]?.scrollLeft
+  const selectedLines = (path: string) => view.file[path]?.selectedLines
+
+  const setScrollTop = (path: string, top: number) => {
+    setView("file", path, (current) => {
+      if (current?.scrollTop === top) return current
+      return {
+        ...(current ?? {}),
+        scrollTop: top,
+      }
+    })
+    pruneView(path)
+  }
+
+  const setScrollLeft = (path: string, left: number) => {
+    setView("file", path, (current) => {
+      if (current?.scrollLeft === left) return current
+      return {
+        ...(current ?? {}),
+        scrollLeft: left,
+      }
+    })
+    pruneView(path)
+  }
+
+  const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
+    const next = range ? normalizeSelectedLines(range) : null
+    setView("file", path, (current) => {
+      if (current?.selectedLines === next) return current
+      return {
+        ...(current ?? {}),
+        selectedLines: next,
+      }
+    })
+    pruneView(path)
+  }
+
+  return {
+    ready,
+    scrollTop,
+    scrollLeft,
+    selectedLines,
+    setScrollTop,
+    setScrollLeft,
+    setSelectedLines,
+  }
+}
+
+export function createFileViewCache() {
+  const cache = createScopedCache(
+    (key) => {
+      const split = key.lastIndexOf("\n")
+      const dir = split >= 0 ? key.slice(0, split) : key
+      const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+      return createRoot((dispose) => ({
+        value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+        dispose,
+      }))
+    },
+    {
+      maxEntries: MAX_FILE_VIEW_SESSIONS,
+      dispose: (entry) => entry.dispose(),
+    },
+  )
+
+  return {
+    load: (dir: string, id: string | undefined) => {
+      const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+      return cache.get(key).value
+    },
+    clear: () => cache.clear(),
+  }
+}

+ 118 - 0
packages/app/src/context/file/watcher.test.ts

@@ -0,0 +1,118 @@
+import { describe, expect, test } from "bun:test"
+import { invalidateFromWatcher } from "./watcher"
+
+describe("file watcher invalidation", () => {
+  test("reloads open files and refreshes loaded parent on add", () => {
+    const loads: string[] = []
+    const refresh: string[] = []
+    invalidateFromWatcher(
+      {
+        type: "file.watcher.updated",
+        properties: {
+          file: "src/new.ts",
+          event: "add",
+        },
+      },
+      {
+        normalize: (input) => input,
+        hasFile: (path) => path === "src/new.ts",
+        loadFile: (path) => loads.push(path),
+        node: () => undefined,
+        isDirLoaded: (path) => path === "src",
+        refreshDir: (path) => refresh.push(path),
+      },
+    )
+
+    expect(loads).toEqual(["src/new.ts"])
+    expect(refresh).toEqual(["src"])
+  })
+
+  test("refreshes only changed loaded directory nodes", () => {
+    const refresh: string[] = []
+
+    invalidateFromWatcher(
+      {
+        type: "file.watcher.updated",
+        properties: {
+          file: "src",
+          event: "change",
+        },
+      },
+      {
+        normalize: (input) => input,
+        hasFile: () => false,
+        loadFile: () => {},
+        node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
+        isDirLoaded: (path) => path === "src",
+        refreshDir: (path) => refresh.push(path),
+      },
+    )
+
+    invalidateFromWatcher(
+      {
+        type: "file.watcher.updated",
+        properties: {
+          file: "src/file.ts",
+          event: "change",
+        },
+      },
+      {
+        normalize: (input) => input,
+        hasFile: () => false,
+        loadFile: () => {},
+        node: () => ({
+          path: "src/file.ts",
+          type: "file",
+          name: "file.ts",
+          absolute: "/repo/src/file.ts",
+          ignored: false,
+        }),
+        isDirLoaded: () => true,
+        refreshDir: (path) => refresh.push(path),
+      },
+    )
+
+    expect(refresh).toEqual(["src"])
+  })
+
+  test("ignores invalid or git watcher updates", () => {
+    const refresh: string[] = []
+
+    invalidateFromWatcher(
+      {
+        type: "file.watcher.updated",
+        properties: {
+          file: ".git/index.lock",
+          event: "change",
+        },
+      },
+      {
+        normalize: (input) => input,
+        hasFile: () => true,
+        loadFile: () => {
+          throw new Error("should not load")
+        },
+        node: () => undefined,
+        isDirLoaded: () => true,
+        refreshDir: (path) => refresh.push(path),
+      },
+    )
+
+    invalidateFromWatcher(
+      {
+        type: "project.updated",
+        properties: {},
+      },
+      {
+        normalize: (input) => input,
+        hasFile: () => false,
+        loadFile: () => {},
+        node: () => undefined,
+        isDirLoaded: () => true,
+        refreshDir: (path) => refresh.push(path),
+      },
+    )
+
+    expect(refresh).toEqual([])
+  })
+})

+ 52 - 0
packages/app/src/context/file/watcher.ts

@@ -0,0 +1,52 @@
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type WatcherEvent = {
+  type: string
+  properties: unknown
+}
+
+type WatcherOps = {
+  normalize: (input: string) => string
+  hasFile: (path: string) => boolean
+  loadFile: (path: string) => void
+  node: (path: string) => FileNode | undefined
+  isDirLoaded: (path: string) => boolean
+  refreshDir: (path: string) => void
+}
+
+export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
+  if (event.type !== "file.watcher.updated") return
+  const props =
+    typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
+  const rawPath = typeof props?.file === "string" ? props.file : undefined
+  const kind = typeof props?.event === "string" ? props.event : undefined
+  if (!rawPath) return
+  if (!kind) return
+
+  const path = ops.normalize(rawPath)
+  if (!path) return
+  if (path.startsWith(".git/")) return
+
+  if (ops.hasFile(path)) {
+    ops.loadFile(path)
+  }
+
+  if (kind === "change") {
+    const dir = (() => {
+      if (path === "") return ""
+      const node = ops.node(path)
+      if (node?.type !== "directory") return
+      return path
+    })()
+    if (dir === undefined) return
+    if (!ops.isDirLoaded(dir)) return
+    ops.refreshDir(dir)
+    return
+  }
+  if (kind !== "add" && kind !== "unlink") return
+
+  const parent = path.split("/").slice(0, -1).join("/")
+  if (!ops.isDirLoaded(parent)) return
+
+  ops.refreshDir(parent)
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 120 - 1100
packages/app/src/context/global-sync.tsx


+ 195 - 0
packages/app/src/context/global-sync/bootstrap.ts

@@ -0,0 +1,195 @@
+import {
+  type Config,
+  type Path,
+  type PermissionRequest,
+  type Project,
+  type ProviderAuthResponse,
+  type ProviderListResponse,
+  type QuestionRequest,
+  createOpencodeClient,
+} from "@opencode-ai/sdk/v2/client"
+import { batch } from "solid-js"
+import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import { retry } from "@opencode-ai/util/retry"
+import { getFilename } from "@opencode-ai/util/path"
+import { showToast } from "@opencode-ai/ui/toast"
+import { cmp, normalizeProviderList } from "./utils"
+import type { State, VcsCache } from "./types"
+
+type GlobalStore = {
+  ready: boolean
+  path: Path
+  project: Project[]
+  provider: ProviderListResponse
+  provider_auth: ProviderAuthResponse
+  config: Config
+  reload: undefined | "pending" | "complete"
+}
+
+export async function bootstrapGlobal(input: {
+  globalSDK: ReturnType<typeof createOpencodeClient>
+  connectErrorTitle: string
+  connectErrorDescription: string
+  requestFailedTitle: string
+  setGlobalStore: SetStoreFunction<GlobalStore>
+}) {
+  const health = await input.globalSDK.global
+    .health()
+    .then((x) => x.data)
+    .catch(() => undefined)
+  if (!health?.healthy) {
+    showToast({
+      variant: "error",
+      title: input.connectErrorTitle,
+      description: input.connectErrorDescription,
+    })
+    input.setGlobalStore("ready", true)
+    return
+  }
+
+  const tasks = [
+    retry(() =>
+      input.globalSDK.path.get().then((x) => {
+        input.setGlobalStore("path", x.data!)
+      }),
+    ),
+    retry(() =>
+      input.globalSDK.global.config.get().then((x) => {
+        input.setGlobalStore("config", x.data!)
+      }),
+    ),
+    retry(() =>
+      input.globalSDK.project.list().then((x) => {
+        const projects = (x.data ?? [])
+          .filter((p) => !!p?.id)
+          .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+          .slice()
+          .sort((a, b) => cmp(a.id, b.id))
+        input.setGlobalStore("project", projects)
+      }),
+    ),
+    retry(() =>
+      input.globalSDK.provider.list().then((x) => {
+        input.setGlobalStore("provider", normalizeProviderList(x.data!))
+      }),
+    ),
+    retry(() =>
+      input.globalSDK.provider.auth().then((x) => {
+        input.setGlobalStore("provider_auth", x.data ?? {})
+      }),
+    ),
+  ]
+
+  const results = await Promise.allSettled(tasks)
+  const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
+  if (errors.length) {
+    const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
+    const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
+    showToast({
+      variant: "error",
+      title: input.requestFailedTitle,
+      description: message + more,
+    })
+  }
+  input.setGlobalStore("ready", true)
+}
+
+function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
+  return input.reduce<Record<string, T[]>>((acc, item) => {
+    if (!item?.id || !item.sessionID) return acc
+    const list = acc[item.sessionID]
+    if (list) list.push(item)
+    if (!list) acc[item.sessionID] = [item]
+    return acc
+  }, {})
+}
+
+export async function bootstrapDirectory(input: {
+  directory: string
+  sdk: ReturnType<typeof createOpencodeClient>
+  store: Store<State>
+  setStore: SetStoreFunction<State>
+  vcsCache: VcsCache
+  loadSessions: (directory: string) => Promise<void> | void
+}) {
+  input.setStore("status", "loading")
+
+  const blockingRequests = {
+    project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
+    provider: () =>
+      input.sdk.provider.list().then((x) => {
+        input.setStore("provider", normalizeProviderList(x.data!))
+      }),
+    agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
+    config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+  }
+
+  try {
+    await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+  } catch (err) {
+    console.error("Failed to bootstrap instance", err)
+    const project = getFilename(input.directory)
+    const message = err instanceof Error ? err.message : String(err)
+    showToast({ title: `Failed to reload ${project}`, description: message })
+    input.setStore("status", "partial")
+    return
+  }
+
+  if (input.store.status !== "complete") input.setStore("status", "partial")
+
+  Promise.all([
+    input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
+    input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
+    input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
+    input.loadSessions(input.directory),
+    input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
+    input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
+    input.sdk.vcs.get().then((x) => {
+      const next = x.data ?? input.store.vcs
+      input.setStore("vcs", next)
+      if (next?.branch) input.vcsCache.setStore("value", next)
+    }),
+    input.sdk.permission.list().then((x) => {
+      const grouped = groupBySession(
+        (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+      )
+      batch(() => {
+        for (const sessionID of Object.keys(input.store.permission)) {
+          if (grouped[sessionID]) continue
+          input.setStore("permission", sessionID, [])
+        }
+        for (const [sessionID, permissions] of Object.entries(grouped)) {
+          input.setStore(
+            "permission",
+            sessionID,
+            reconcile(
+              permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+              { key: "id" },
+            ),
+          )
+        }
+      })
+    }),
+    input.sdk.question.list().then((x) => {
+      const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+      batch(() => {
+        for (const sessionID of Object.keys(input.store.question)) {
+          if (grouped[sessionID]) continue
+          input.setStore("question", sessionID, [])
+        }
+        for (const [sessionID, questions] of Object.entries(grouped)) {
+          input.setStore(
+            "question",
+            sessionID,
+            reconcile(
+              questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+              { key: "id" },
+            ),
+          )
+        }
+      })
+    }),
+  ]).then(() => {
+    input.setStore("status", "complete")
+  })
+}

+ 263 - 0
packages/app/src/context/global-sync/child-store.ts

@@ -0,0 +1,263 @@
+import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
+import {
+  DIR_IDLE_TTL_MS,
+  MAX_DIR_STORES,
+  type ChildOptions,
+  type DirState,
+  type IconCache,
+  type MetaCache,
+  type ProjectMeta,
+  type State,
+  type VcsCache,
+} from "./types"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+
+export function createChildStoreManager(input: {
+  owner: Owner
+  markStats: (activeDirectoryStores: number) => void
+  incrementEvictions: () => void
+  isBooting: (directory: string) => boolean
+  isLoadingSessions: (directory: string) => boolean
+  onBootstrap: (directory: string) => void
+  onDispose: (directory: string) => void
+}) {
+  const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
+  const vcsCache = new Map<string, VcsCache>()
+  const metaCache = new Map<string, MetaCache>()
+  const iconCache = new Map<string, IconCache>()
+  const lifecycle = new Map<string, DirState>()
+  const pins = new Map<string, number>()
+  const ownerPins = new WeakMap<object, Set<string>>()
+  const disposers = new Map<string, () => void>()
+
+  const mark = (directory: string) => {
+    if (!directory) return
+    lifecycle.set(directory, { lastAccessAt: Date.now() })
+    runEviction()
+  }
+
+  const pin = (directory: string) => {
+    if (!directory) return
+    pins.set(directory, (pins.get(directory) ?? 0) + 1)
+    mark(directory)
+  }
+
+  const unpin = (directory: string) => {
+    if (!directory) return
+    const next = (pins.get(directory) ?? 0) - 1
+    if (next > 0) {
+      pins.set(directory, next)
+      return
+    }
+    pins.delete(directory)
+    runEviction()
+  }
+
+  const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+  const pinForOwner = (directory: string) => {
+    const current = getOwner()
+    if (!current) return
+    if (current === input.owner) return
+    const key = current as object
+    const set = ownerPins.get(key)
+    if (set?.has(directory)) return
+    if (set) set.add(directory)
+    if (!set) ownerPins.set(key, new Set([directory]))
+    pin(directory)
+    onCleanup(() => {
+      const set = ownerPins.get(key)
+      if (set) {
+        set.delete(directory)
+        if (set.size === 0) ownerPins.delete(key)
+      }
+      unpin(directory)
+    })
+  }
+
+  function disposeDirectory(directory: string) {
+    if (
+      !canDisposeDirectory({
+        directory,
+        hasStore: !!children[directory],
+        pinned: pinned(directory),
+        booting: input.isBooting(directory),
+        loadingSessions: input.isLoadingSessions(directory),
+      })
+    ) {
+      return false
+    }
+
+    vcsCache.delete(directory)
+    metaCache.delete(directory)
+    iconCache.delete(directory)
+    lifecycle.delete(directory)
+    const dispose = disposers.get(directory)
+    if (dispose) {
+      dispose()
+      disposers.delete(directory)
+    }
+    delete children[directory]
+    input.onDispose(directory)
+    input.markStats(Object.keys(children).length)
+    return true
+  }
+
+  function runEviction() {
+    const stores = Object.keys(children)
+    if (stores.length === 0) return
+    const list = pickDirectoriesToEvict({
+      stores,
+      state: lifecycle,
+      pins: new Set(stores.filter(pinned)),
+      max: MAX_DIR_STORES,
+      ttl: DIR_IDLE_TTL_MS,
+      now: Date.now(),
+    })
+    if (list.length === 0) return
+    for (const directory of list) {
+      if (!disposeDirectory(directory)) continue
+      input.incrementEvictions()
+    }
+  }
+
+  function ensureChild(directory: string) {
+    if (!directory) console.error("No directory provided")
+    if (!children[directory]) {
+      const vcs = runWithOwner(input.owner, () =>
+        persisted(
+          Persist.workspace(directory, "vcs", ["vcs.v1"]),
+          createStore({ value: undefined as VcsInfo | undefined }),
+        ),
+      )
+      if (!vcs) throw new Error("Failed to create persisted cache")
+      const vcsStore = vcs[0]
+      const vcsReady = vcs[3]
+      vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+
+      const meta = runWithOwner(input.owner, () =>
+        persisted(
+          Persist.workspace(directory, "project", ["project.v1"]),
+          createStore({ value: undefined as ProjectMeta | undefined }),
+        ),
+      )
+      if (!meta) throw new Error("Failed to create persisted project metadata")
+      metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
+
+      const icon = runWithOwner(input.owner, () =>
+        persisted(
+          Persist.workspace(directory, "icon", ["icon.v1"]),
+          createStore({ value: undefined as string | undefined }),
+        ),
+      )
+      if (!icon) throw new Error("Failed to create persisted project icon")
+      iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
+
+      const init = () =>
+        createRoot((dispose) => {
+          const child = createStore<State>({
+            project: "",
+            projectMeta: meta[0].value,
+            icon: icon[0].value,
+            provider: { all: [], connected: [], default: {} },
+            config: {},
+            path: { state: "", config: "", worktree: "", directory: "", home: "" },
+            status: "loading" as const,
+            agent: [],
+            command: [],
+            session: [],
+            sessionTotal: 0,
+            session_status: {},
+            session_diff: {},
+            todo: {},
+            permission: {},
+            question: {},
+            mcp: {},
+            lsp: [],
+            vcs: vcsStore.value,
+            limit: 5,
+            message: {},
+            part: {},
+          })
+          children[directory] = child
+          disposers.set(directory, dispose)
+
+          createEffect(() => {
+            if (!vcsReady()) return
+            const cached = vcsStore.value
+            if (!cached?.branch) return
+            child[1]("vcs", (value) => value ?? cached)
+          })
+          createEffect(() => {
+            child[1]("projectMeta", meta[0].value)
+          })
+          createEffect(() => {
+            child[1]("icon", icon[0].value)
+          })
+        })
+
+      runWithOwner(input.owner, init)
+      input.markStats(Object.keys(children).length)
+    }
+    mark(directory)
+    const childStore = children[directory]
+    if (!childStore) throw new Error("Failed to create store")
+    return childStore
+  }
+
+  function child(directory: string, options: ChildOptions = {}) {
+    const childStore = ensureChild(directory)
+    pinForOwner(directory)
+    const shouldBootstrap = options.bootstrap ?? true
+    if (shouldBootstrap && childStore[0].status === "loading") {
+      input.onBootstrap(directory)
+    }
+    return childStore
+  }
+
+  function projectMeta(directory: string, patch: ProjectMeta) {
+    const [store, setStore] = ensureChild(directory)
+    const cached = metaCache.get(directory)
+    if (!cached) return
+    const previous = store.projectMeta ?? {}
+    const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
+    const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
+    const next = {
+      ...previous,
+      ...patch,
+      icon,
+      commands,
+    }
+    cached.setStore("value", next)
+    setStore("projectMeta", next)
+  }
+
+  function projectIcon(directory: string, value: string | undefined) {
+    const [store, setStore] = ensureChild(directory)
+    const cached = iconCache.get(directory)
+    if (!cached) return
+    if (store.icon === value) return
+    cached.setStore("value", value)
+    setStore("icon", value)
+  }
+
+  return {
+    children,
+    ensureChild,
+    child,
+    projectMeta,
+    projectIcon,
+    mark,
+    pin,
+    unpin,
+    pinned,
+    disposeDirectory,
+    runEviction,
+    vcsCache,
+    metaCache,
+    iconCache,
+  }
+}

+ 201 - 0
packages/app/src/context/global-sync/event-reducer.test.ts

@@ -0,0 +1,201 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
+import { createStore } from "solid-js/store"
+import type { State } from "./types"
+import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
+
+const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
+  ({
+    id: input.id,
+    parentID: input.parentID,
+    time: {
+      created: 1,
+      updated: 1,
+      archived: input.archived,
+    },
+  }) as Session
+
+const userMessage = (id: string, sessionID: string) =>
+  ({
+    id,
+    sessionID,
+    role: "user",
+    time: { created: 1 },
+    agent: "assistant",
+    model: { providerID: "openai", modelID: "gpt" },
+  }) as Message
+
+const textPart = (id: string, sessionID: string, messageID: string) =>
+  ({
+    id,
+    sessionID,
+    messageID,
+    type: "text",
+    text: id,
+  }) as Part
+
+const baseState = (input: Partial<State> = {}) =>
+  ({
+    status: "complete",
+    agent: [],
+    command: [],
+    project: "",
+    projectMeta: undefined,
+    icon: undefined,
+    provider: {} as State["provider"],
+    config: {} as State["config"],
+    path: { directory: "/tmp" } as State["path"],
+    session: [],
+    sessionTotal: 0,
+    session_status: {},
+    session_diff: {},
+    todo: {},
+    permission: {},
+    question: {},
+    mcp: {},
+    lsp: [],
+    vcs: undefined,
+    limit: 10,
+    message: {},
+    part: {},
+    ...input,
+  }) as State
+
+describe("applyGlobalEvent", () => {
+  test("upserts project.updated in sorted position", () => {
+    const project = [{ id: "a" }, { id: "c" }] as Project[]
+    let refreshCount = 0
+    applyGlobalEvent({
+      event: { type: "project.updated", properties: { id: "b" } },
+      project,
+      refresh: () => {
+        refreshCount += 1
+      },
+      setGlobalProject(next) {
+        if (typeof next === "function") next(project)
+      },
+    })
+
+    expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
+    expect(refreshCount).toBe(0)
+  })
+
+  test("handles global.disposed by triggering refresh", () => {
+    let refreshCount = 0
+    applyGlobalEvent({
+      event: { type: "global.disposed" },
+      project: [],
+      refresh: () => {
+        refreshCount += 1
+      },
+      setGlobalProject() {},
+    })
+
+    expect(refreshCount).toBe(1)
+  })
+})
+
+describe("applyDirectoryEvent", () => {
+  test("inserts root sessions in sorted order and updates sessionTotal", () => {
+    const [store, setStore] = createStore(
+      baseState({
+        session: [rootSession({ id: "b" })],
+        sessionTotal: 1,
+      }),
+    )
+
+    applyDirectoryEvent({
+      event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
+      store,
+      setStore,
+      push() {},
+      directory: "/tmp",
+      loadLsp() {},
+    })
+
+    expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
+    expect(store.sessionTotal).toBe(2)
+
+    applyDirectoryEvent({
+      event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
+      store,
+      setStore,
+      push() {},
+      directory: "/tmp",
+      loadLsp() {},
+    })
+
+    expect(store.sessionTotal).toBe(2)
+  })
+
+  test("cleans session caches when archived", () => {
+    const message = userMessage("msg_1", "ses_1")
+    const [store, setStore] = createStore(
+      baseState({
+        session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
+        sessionTotal: 2,
+        message: { ses_1: [message] },
+        part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
+        session_diff: { ses_1: [] },
+        todo: { ses_1: [] },
+        permission: { ses_1: [] },
+        question: { ses_1: [] },
+        session_status: { ses_1: { type: "busy" } },
+      }),
+    )
+
+    applyDirectoryEvent({
+      event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
+      store,
+      setStore,
+      push() {},
+      directory: "/tmp",
+      loadLsp() {},
+    })
+
+    expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
+    expect(store.sessionTotal).toBe(1)
+    expect(store.message.ses_1).toBeUndefined()
+    expect(store.part[message.id]).toBeUndefined()
+    expect(store.session_diff.ses_1).toBeUndefined()
+    expect(store.todo.ses_1).toBeUndefined()
+    expect(store.permission.ses_1).toBeUndefined()
+    expect(store.question.ses_1).toBeUndefined()
+    expect(store.session_status.ses_1).toBeUndefined()
+  })
+
+  test("routes disposal and lsp events to side-effect handlers", () => {
+    const [store, setStore] = createStore(baseState())
+    const pushes: string[] = []
+    let lspLoads = 0
+
+    applyDirectoryEvent({
+      event: { type: "server.instance.disposed" },
+      store,
+      setStore,
+      push(directory) {
+        pushes.push(directory)
+      },
+      directory: "/tmp",
+      loadLsp() {
+        lspLoads += 1
+      },
+    })
+
+    applyDirectoryEvent({
+      event: { type: "lsp.updated" },
+      store,
+      setStore,
+      push(directory) {
+        pushes.push(directory)
+      },
+      directory: "/tmp",
+      loadLsp() {
+        lspLoads += 1
+      },
+    })
+
+    expect(pushes).toEqual(["/tmp"])
+    expect(lspLoads).toBe(1)
+  })
+})

+ 319 - 0
packages/app/src/context/global-sync/event-reducer.ts

@@ -0,0 +1,319 @@
+import { Binary } from "@opencode-ai/util/binary"
+import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import type {
+  FileDiff,
+  Message,
+  Part,
+  PermissionRequest,
+  Project,
+  QuestionRequest,
+  Session,
+  SessionStatus,
+  Todo,
+} from "@opencode-ai/sdk/v2/client"
+import type { State, VcsCache } from "./types"
+import { trimSessions } from "./session-trim"
+
+export function applyGlobalEvent(input: {
+  event: { type: string; properties?: unknown }
+  project: Project[]
+  setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+  refresh: () => void
+}) {
+  if (input.event.type === "global.disposed") {
+    input.refresh()
+    return
+  }
+
+  if (input.event.type !== "project.updated") return
+  const properties = input.event.properties as Project
+  const result = Binary.search(input.project, properties.id, (s) => s.id)
+  if (result.found) {
+    input.setGlobalProject((draft) => {
+      draft[result.index] = { ...draft[result.index], ...properties }
+    })
+    return
+  }
+  input.setGlobalProject((draft) => {
+    draft.splice(result.index, 0, properties)
+  })
+}
+
+function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
+  if (!sessionID) return
+  const hasAny =
+    store.message[sessionID] !== undefined ||
+    store.session_diff[sessionID] !== undefined ||
+    store.todo[sessionID] !== undefined ||
+    store.permission[sessionID] !== undefined ||
+    store.question[sessionID] !== undefined ||
+    store.session_status[sessionID] !== undefined
+  if (!hasAny) return
+  setStore(
+    produce((draft) => {
+      const messages = draft.message[sessionID]
+      if (messages) {
+        for (const message of messages) {
+          const id = message?.id
+          if (!id) continue
+          delete draft.part[id]
+        }
+      }
+      delete draft.message[sessionID]
+      delete draft.session_diff[sessionID]
+      delete draft.todo[sessionID]
+      delete draft.permission[sessionID]
+      delete draft.question[sessionID]
+      delete draft.session_status[sessionID]
+    }),
+  )
+}
+
+export function applyDirectoryEvent(input: {
+  event: { type: string; properties?: unknown }
+  store: Store<State>
+  setStore: SetStoreFunction<State>
+  push: (directory: string) => void
+  directory: string
+  loadLsp: () => void
+  vcsCache?: VcsCache
+}) {
+  const event = input.event
+  switch (event.type) {
+    case "server.instance.disposed": {
+      input.push(input.directory)
+      return
+    }
+    case "session.created": {
+      const info = (event.properties as { info: Session }).info
+      const result = Binary.search(input.store.session, info.id, (s) => s.id)
+      if (result.found) {
+        input.setStore("session", result.index, reconcile(info))
+        break
+      }
+      const next = input.store.session.slice()
+      next.splice(result.index, 0, info)
+      const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+      input.setStore("session", reconcile(trimmed, { key: "id" }))
+      if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
+      break
+    }
+    case "session.updated": {
+      const info = (event.properties as { info: Session }).info
+      const result = Binary.search(input.store.session, info.id, (s) => s.id)
+      if (info.time.archived) {
+        if (result.found) {
+          input.setStore(
+            "session",
+            produce((draft) => {
+              draft.splice(result.index, 1)
+            }),
+          )
+        }
+        cleanupSessionCaches(input.store, input.setStore, info.id)
+        if (info.parentID) break
+        input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+        break
+      }
+      if (result.found) {
+        input.setStore("session", result.index, reconcile(info))
+        break
+      }
+      const next = input.store.session.slice()
+      next.splice(result.index, 0, info)
+      const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+      input.setStore("session", reconcile(trimmed, { key: "id" }))
+      break
+    }
+    case "session.deleted": {
+      const info = (event.properties as { info: Session }).info
+      const result = Binary.search(input.store.session, info.id, (s) => s.id)
+      if (result.found) {
+        input.setStore(
+          "session",
+          produce((draft) => {
+            draft.splice(result.index, 1)
+          }),
+        )
+      }
+      cleanupSessionCaches(input.store, input.setStore, info.id)
+      if (info.parentID) break
+      input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+      break
+    }
+    case "session.diff": {
+      const props = event.properties as { sessionID: string; diff: FileDiff[] }
+      input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+      break
+    }
+    case "todo.updated": {
+      const props = event.properties as { sessionID: string; todos: Todo[] }
+      input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+      break
+    }
+    case "session.status": {
+      const props = event.properties as { sessionID: string; status: SessionStatus }
+      input.setStore("session_status", props.sessionID, reconcile(props.status))
+      break
+    }
+    case "message.updated": {
+      const info = (event.properties as { info: Message }).info
+      const messages = input.store.message[info.sessionID]
+      if (!messages) {
+        input.setStore("message", info.sessionID, [info])
+        break
+      }
+      const result = Binary.search(messages, info.id, (m) => m.id)
+      if (result.found) {
+        input.setStore("message", info.sessionID, result.index, reconcile(info))
+        break
+      }
+      input.setStore(
+        "message",
+        info.sessionID,
+        produce((draft) => {
+          draft.splice(result.index, 0, info)
+        }),
+      )
+      break
+    }
+    case "message.removed": {
+      const props = event.properties as { sessionID: string; messageID: string }
+      input.setStore(
+        produce((draft) => {
+          const messages = draft.message[props.sessionID]
+          if (messages) {
+            const result = Binary.search(messages, props.messageID, (m) => m.id)
+            if (result.found) messages.splice(result.index, 1)
+          }
+          delete draft.part[props.messageID]
+        }),
+      )
+      break
+    }
+    case "message.part.updated": {
+      const part = (event.properties as { part: Part }).part
+      const parts = input.store.part[part.messageID]
+      if (!parts) {
+        input.setStore("part", part.messageID, [part])
+        break
+      }
+      const result = Binary.search(parts, part.id, (p) => p.id)
+      if (result.found) {
+        input.setStore("part", part.messageID, result.index, reconcile(part))
+        break
+      }
+      input.setStore(
+        "part",
+        part.messageID,
+        produce((draft) => {
+          draft.splice(result.index, 0, part)
+        }),
+      )
+      break
+    }
+    case "message.part.removed": {
+      const props = event.properties as { messageID: string; partID: string }
+      const parts = input.store.part[props.messageID]
+      if (!parts) break
+      const result = Binary.search(parts, props.partID, (p) => p.id)
+      if (result.found) {
+        input.setStore(
+          produce((draft) => {
+            const list = draft.part[props.messageID]
+            if (!list) return
+            const next = Binary.search(list, props.partID, (p) => p.id)
+            if (!next.found) return
+            list.splice(next.index, 1)
+            if (list.length === 0) delete draft.part[props.messageID]
+          }),
+        )
+      }
+      break
+    }
+    case "vcs.branch.updated": {
+      const props = event.properties as { branch: string }
+      const next = { branch: props.branch }
+      input.setStore("vcs", next)
+      if (input.vcsCache) input.vcsCache.setStore("value", next)
+      break
+    }
+    case "permission.asked": {
+      const permission = event.properties as PermissionRequest
+      const permissions = input.store.permission[permission.sessionID]
+      if (!permissions) {
+        input.setStore("permission", permission.sessionID, [permission])
+        break
+      }
+      const result = Binary.search(permissions, permission.id, (p) => p.id)
+      if (result.found) {
+        input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
+        break
+      }
+      input.setStore(
+        "permission",
+        permission.sessionID,
+        produce((draft) => {
+          draft.splice(result.index, 0, permission)
+        }),
+      )
+      break
+    }
+    case "permission.replied": {
+      const props = event.properties as { sessionID: string; requestID: string }
+      const permissions = input.store.permission[props.sessionID]
+      if (!permissions) break
+      const result = Binary.search(permissions, props.requestID, (p) => p.id)
+      if (!result.found) break
+      input.setStore(
+        "permission",
+        props.sessionID,
+        produce((draft) => {
+          draft.splice(result.index, 1)
+        }),
+      )
+      break
+    }
+    case "question.asked": {
+      const question = event.properties as QuestionRequest
+      const questions = input.store.question[question.sessionID]
+      if (!questions) {
+        input.setStore("question", question.sessionID, [question])
+        break
+      }
+      const result = Binary.search(questions, question.id, (q) => q.id)
+      if (result.found) {
+        input.setStore("question", question.sessionID, result.index, reconcile(question))
+        break
+      }
+      input.setStore(
+        "question",
+        question.sessionID,
+        produce((draft) => {
+          draft.splice(result.index, 0, question)
+        }),
+      )
+      break
+    }
+    case "question.replied":
+    case "question.rejected": {
+      const props = event.properties as { sessionID: string; requestID: string }
+      const questions = input.store.question[props.sessionID]
+      if (!questions) break
+      const result = Binary.search(questions, props.requestID, (q) => q.id)
+      if (!result.found) break
+      input.setStore(
+        "question",
+        props.sessionID,
+        produce((draft) => {
+          draft.splice(result.index, 1)
+        }),
+      )
+      break
+    }
+    case "lsp.updated": {
+      input.loadLsp()
+      break
+    }
+  }
+}

+ 28 - 0
packages/app/src/context/global-sync/eviction.ts

@@ -0,0 +1,28 @@
+import type { DisposeCheck, EvictPlan } from "./types"
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+  const overflow = Math.max(0, input.stores.length - input.max)
+  let pendingOverflow = overflow
+  const sorted = input.stores
+    .filter((dir) => !input.pins.has(dir))
+    .slice()
+    .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+  const output: string[] = []
+  for (const dir of sorted) {
+    const last = input.state.get(dir)?.lastAccessAt ?? 0
+    const idle = input.now - last >= input.ttl
+    if (!idle && pendingOverflow <= 0) continue
+    output.push(dir)
+    if (pendingOverflow > 0) pendingOverflow -= 1
+  }
+  return output
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+  if (!input.directory) return false
+  if (!input.hasStore) return false
+  if (input.pinned) return false
+  if (input.booting) return false
+  if (input.loadingSessions) return false
+  return true
+}

+ 83 - 0
packages/app/src/context/global-sync/queue.ts

@@ -0,0 +1,83 @@
+type QueueInput = {
+  paused: () => boolean
+  bootstrap: () => Promise<void>
+  bootstrapInstance: (directory: string) => Promise<void> | void
+}
+
+export function createRefreshQueue(input: QueueInput) {
+  const queued = new Set<string>()
+  let root = false
+  let running = false
+  let timer: ReturnType<typeof setTimeout> | undefined
+
+  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
+
+  const take = (count: number) => {
+    if (queued.size === 0) return [] as string[]
+    const items: string[] = []
+    for (const item of queued) {
+      queued.delete(item)
+      items.push(item)
+      if (items.length >= count) break
+    }
+    return items
+  }
+
+  const schedule = () => {
+    if (timer) return
+    timer = setTimeout(() => {
+      timer = undefined
+      void drain()
+    }, 0)
+  }
+
+  const push = (directory: string) => {
+    if (!directory) return
+    queued.add(directory)
+    if (input.paused()) return
+    schedule()
+  }
+
+  const refresh = () => {
+    root = true
+    if (input.paused()) return
+    schedule()
+  }
+
+  async function drain() {
+    if (running) return
+    running = true
+    try {
+      while (true) {
+        if (input.paused()) return
+        if (root) {
+          root = false
+          await input.bootstrap()
+          await tick()
+          continue
+        }
+        const dirs = take(2)
+        if (dirs.length === 0) return
+        await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
+        await tick()
+      }
+    } finally {
+      running = false
+      if (input.paused()) return
+      if (root || queued.size) schedule()
+    }
+  }
+
+  return {
+    push,
+    refresh,
+    clear(directory: string) {
+      queued.delete(directory)
+    },
+    dispose() {
+      if (!timer) return
+      clearTimeout(timer)
+      timer = undefined
+    },
+  }
+}

+ 26 - 0
packages/app/src/context/global-sync/session-load.ts

@@ -0,0 +1,26 @@
+import type { RootLoadArgs } from "./types"
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+  try {
+    const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+    return {
+      data: result.data,
+      limit: input.limit,
+      limited: true,
+    } as const
+  } catch {
+    input.onFallback()
+    const result = await input.list({ directory: input.directory, roots: true })
+    return {
+      data: result.data,
+      limit: input.limit,
+      limited: false,
+    } as const
+  }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+  if (!input.limited) return input.count
+  if (input.count < input.limit) return input.count
+  return input.count + 1
+}

+ 59 - 0
packages/app/src/context/global-sync/session-trim.test.ts

@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { trimSessions } from "./session-trim"
+
+const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
+  ({
+    id: input.id,
+    parentID: input.parentID,
+    time: {
+      created: input.created,
+      updated: input.updated,
+      archived: input.archived,
+    },
+  }) as Session
+
+describe("trimSessions", () => {
+  test("keeps base roots and recent roots beyond the limit", () => {
+    const now = 1_000_000
+    const list = [
+      session({ id: "a", created: now - 100_000 }),
+      session({ id: "b", created: now - 90_000 }),
+      session({ id: "c", created: now - 80_000 }),
+      session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
+      session({ id: "e", created: now - 60_000, archived: now - 10 }),
+    ]
+
+    const result = trimSessions(list, { limit: 2, permission: {}, now })
+    expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
+  })
+
+  test("keeps children when root is kept, permission exists, or child is recent", () => {
+    const now = 1_000_000
+    const list = [
+      session({ id: "root-1", created: now - 1000 }),
+      session({ id: "root-2", created: now - 2000 }),
+      session({ id: "z-root", created: now - 30_000_000 }),
+      session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
+      session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
+      session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
+      session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
+    ]
+
+    const result = trimSessions(list, {
+      limit: 2,
+      permission: {
+        "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
+      },
+      now,
+    })
+
+    expect(result.map((x) => x.id)).toEqual([
+      "child-kept-by-permission",
+      "child-kept-by-recency",
+      "child-kept-by-root",
+      "root-1",
+      "root-2",
+    ])
+  })
+})

+ 56 - 0
packages/app/src/context/global-sync/session-trim.ts

@@ -0,0 +1,56 @@
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { cmp } from "./utils"
+import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
+
+export function sessionUpdatedAt(session: Session) {
+  return session.time.updated ?? session.time.created
+}
+
+export function compareSessionRecent(a: Session, b: Session) {
+  const aUpdated = sessionUpdatedAt(a)
+  const bUpdated = sessionUpdatedAt(b)
+  if (aUpdated !== bUpdated) return bUpdated - aUpdated
+  return cmp(a.id, b.id)
+}
+
+export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
+  if (limit <= 0) return [] as Session[]
+  const selected: Session[] = []
+  const seen = new Set<string>()
+  for (const session of sessions) {
+    if (!session?.id) continue
+    if (seen.has(session.id)) continue
+    seen.add(session.id)
+    if (sessionUpdatedAt(session) <= cutoff) continue
+    const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
+    if (index === -1) selected.push(session)
+    if (index !== -1) selected.splice(index, 0, session)
+    if (selected.length > limit) selected.pop()
+  }
+  return selected
+}
+
+export function trimSessions(
+  input: Session[],
+  options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
+) {
+  const limit = Math.max(0, options.limit)
+  const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+  const all = input
+    .filter((s) => !!s?.id)
+    .filter((s) => !s.time?.archived)
+    .sort((a, b) => cmp(a.id, b.id))
+  const roots = all.filter((s) => !s.parentID)
+  const children = all.filter((s) => !!s.parentID)
+  const base = roots.slice(0, limit)
+  const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+  const keepRoots = [...base, ...recent]
+  const keepRootIds = new Set(keepRoots.map((s) => s.id))
+  const keepChildren = children.filter((s) => {
+    if (s.parentID && keepRootIds.has(s.parentID)) return true
+    const perms = options.permission[s.id] ?? []
+    if (perms.length > 0) return true
+    return sessionUpdatedAt(s) > cutoff
+  })
+  return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
+}

+ 134 - 0
packages/app/src/context/global-sync/types.ts

@@ -0,0 +1,134 @@
+import type {
+  Agent,
+  Command,
+  Config,
+  FileDiff,
+  LspStatus,
+  McpStatus,
+  Message,
+  Part,
+  Path,
+  PermissionRequest,
+  Project,
+  ProviderListResponse,
+  QuestionRequest,
+  Session,
+  SessionStatus,
+  Todo,
+  VcsInfo,
+} from "@opencode-ai/sdk/v2/client"
+import type { Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+export type ProjectMeta = {
+  name?: string
+  icon?: {
+    override?: string
+    color?: string
+  }
+  commands?: {
+    start?: string
+  }
+}
+
+export type State = {
+  status: "loading" | "partial" | "complete"
+  agent: Agent[]
+  command: Command[]
+  project: string
+  projectMeta: ProjectMeta | undefined
+  icon: string | undefined
+  provider: ProviderListResponse
+  config: Config
+  path: Path
+  session: Session[]
+  sessionTotal: number
+  session_status: {
+    [sessionID: string]: SessionStatus
+  }
+  session_diff: {
+    [sessionID: string]: FileDiff[]
+  }
+  todo: {
+    [sessionID: string]: Todo[]
+  }
+  permission: {
+    [sessionID: string]: PermissionRequest[]
+  }
+  question: {
+    [sessionID: string]: QuestionRequest[]
+  }
+  mcp: {
+    [name: string]: McpStatus
+  }
+  lsp: LspStatus[]
+  vcs: VcsInfo | undefined
+  limit: number
+  message: {
+    [sessionID: string]: Message[]
+  }
+  part: {
+    [messageID: string]: Part[]
+  }
+}
+
+export type VcsCache = {
+  store: Store<{ value: VcsInfo | undefined }>
+  setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
+  ready: Accessor<boolean>
+}
+
+export type MetaCache = {
+  store: Store<{ value: ProjectMeta | undefined }>
+  setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
+  ready: Accessor<boolean>
+}
+
+export type IconCache = {
+  store: Store<{ value: string | undefined }>
+  setStore: SetStoreFunction<{ value: string | undefined }>
+  ready: Accessor<boolean>
+}
+
+export type ChildOptions = {
+  bootstrap?: boolean
+}
+
+export type DirState = {
+  lastAccessAt: number
+}
+
+export type EvictPlan = {
+  stores: string[]
+  state: Map<string, DirState>
+  pins: Set<string>
+  max: number
+  ttl: number
+  now: number
+}
+
+export type DisposeCheck = {
+  directory: string
+  hasStore: boolean
+  pinned: boolean
+  booting: boolean
+  loadingSessions: boolean
+}
+
+export type RootLoadArgs = {
+  directory: string
+  limit: number
+  list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+  onFallback: () => void
+}
+
+export type RootLoadResult = {
+  data?: Session[]
+  limit: number
+  limited: boolean
+}
+
+export const MAX_DIR_STORES = 30
+export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
+export const SESSION_RECENT_LIMIT = 50

+ 25 - 0
packages/app/src/context/global-sync/utils.ts

@@ -0,0 +1,25 @@
+import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+
+export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
+export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
+  return {
+    ...input,
+    all: input.all.map((provider) => ({
+      ...provider,
+      models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
+    })),
+  }
+}
+
+export function sanitizeProject(project: Project) {
+  if (!project.icon?.url && !project.icon?.override) return project
+  return {
+    ...project,
+    icon: {
+      ...project.icon,
+      url: undefined,
+      override: undefined,
+    },
+  }
+}

+ 20 - 0
packages/app/src/context/language.tsx

@@ -76,6 +76,26 @@ const LOCALES: readonly Locale[] = [
   "th",
 ]
 
+type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
+const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
+  zh,
+  zht,
+  ko,
+  de,
+  es,
+  fr,
+  da,
+  ja,
+  pl,
+  ru,
+  ar,
+  no,
+  br,
+  th,
+  bs,
+}
+void PARITY_CHECK
+
 function detectLocale(): Locale {
   if (typeof navigator !== "object") return "en"
 

+ 23 - 60
packages/app/src/context/layout-scroll.test.ts

@@ -1,73 +1,36 @@
 import { describe, expect, test } from "bun:test"
-import { createRoot } from "solid-js"
-import { createStore } from "solid-js/store"
-import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
 import { createScrollPersistence } from "./layout-scroll"
 
 describe("createScrollPersistence", () => {
-  test.skip("debounces persisted scroll writes", async () => {
-    const key = "layout-scroll.test"
-    const data = new Map<string, string>()
-    const writes: string[] = []
-    const stats = { flushes: 0 }
-
-    const storage = {
-      getItem: (k: string) => data.get(k) ?? null,
-      setItem: (k: string, v: string) => {
-        data.set(k, v)
-        if (k === key) writes.push(v)
+  test("debounces persisted scroll writes", async () => {
+    const snapshot = {
+      session: {
+        review: { x: 0, y: 0 },
       },
-      removeItem: (k: string) => {
-        data.delete(k)
+    } as Record<string, Record<string, { x: number; y: number }>>
+    const writes: Array<Record<string, { x: number; y: number }>> = []
+    const scroll = createScrollPersistence({
+      debounceMs: 10,
+      getSnapshot: (sessionKey) => snapshot[sessionKey],
+      onFlush: (sessionKey, next) => {
+        snapshot[sessionKey] = next
+        writes.push(next)
       },
-    } as SyncStorage
-
-    await new Promise<void>((resolve, reject) => {
-      createRoot((dispose) => {
-        const [raw, setRaw] = createStore({
-          sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
-        })
-
-        const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
-
-        const scroll = createScrollPersistence({
-          debounceMs: 30,
-          getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
-          onFlush: (sessionKey, next) => {
-            stats.flushes += 1
-
-            const current = store.sessionView[sessionKey]
-            if (!current) {
-              setStore("sessionView", sessionKey, { scroll: next })
-              return
-            }
-            setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
-          },
-        })
+    })
 
-        const run = async () => {
-          await new Promise((r) => setTimeout(r, 0))
-          writes.length = 0
+    for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
+      scroll.setScroll("session", "review", { x: 0, y: i })
+    }
 
-          for (const i of Array.from({ length: 100 }, (_, n) => n)) {
-            scroll.setScroll("session", "review", { x: 0, y: i })
-          }
+    await new Promise((resolve) => setTimeout(resolve, 40))
 
-          await new Promise((r) => setTimeout(r, 120))
+    expect(writes).toHaveLength(1)
+    expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
 
-          expect(stats.flushes).toBeGreaterThanOrEqual(1)
-          expect(writes.length).toBeGreaterThanOrEqual(1)
-          expect(writes.length).toBeLessThanOrEqual(2)
-        }
+    scroll.setScroll("session", "review", { x: 0, y: 30 })
+    await new Promise((resolve) => setTimeout(resolve, 20))
 
-        void run()
-          .then(resolve)
-          .catch(reject)
-          .finally(() => {
-            scroll.dispose()
-            dispose()
-          })
-      })
-    })
+    expect(writes).toHaveLength(1)
+    scroll.dispose()
   })
 })

+ 3 - 13
packages/app/src/context/server.tsx

@@ -1,9 +1,9 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { batch, createEffect, createMemo, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { Persist, persisted } from "@/utils/persist"
+import { checkServerHealth } from "@/utils/server-health"
 
 type StoredProject = { worktree: string; expanded: boolean }
 
@@ -94,18 +94,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
 
     const isReady = createMemo(() => ready() && !!state.active)
 
-    const check = (url: string) => {
-      const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
-      const sdk = createOpencodeClient({
-        baseUrl: url,
-        fetch: platform.fetch,
-        signal,
-      })
-      return sdk.global
-        .health()
-        .then((x) => x.data?.healthy === true)
-        .catch(() => false)
-    }
+    const fetcher = platform.fetch ?? globalThis.fetch
+    const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
 
     createEffect(() => {
       const url = state.active

+ 56 - 0
packages/app/src/context/sync-optimistic.test.ts

@@ -0,0 +1,56 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
+
+const userMessage = (id: string, sessionID: string): Message => ({
+  id,
+  sessionID,
+  role: "user",
+  time: { created: 1 },
+  agent: "assistant",
+  model: { providerID: "openai", modelID: "gpt" },
+})
+
+const textPart = (id: string, sessionID: string, messageID: string): Part => ({
+  id,
+  sessionID,
+  messageID,
+  type: "text",
+  text: id,
+})
+
+describe("sync optimistic reducers", () => {
+  test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
+    const sessionID = "ses_1"
+    const draft = {
+      message: { [sessionID]: [userMessage("msg_2", sessionID)] },
+      part: {} as Record<string, Part[] | undefined>,
+    }
+
+    applyOptimisticAdd(draft, {
+      sessionID,
+      message: userMessage("msg_1", sessionID),
+      parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
+    })
+
+    expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
+    expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
+  })
+
+  test("applyOptimisticRemove removes message and part entries", () => {
+    const sessionID = "ses_1"
+    const draft = {
+      message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
+      part: {
+        msg_1: [textPart("prt_1", sessionID, "msg_1")],
+        msg_2: [textPart("prt_2", sessionID, "msg_2")],
+      } as Record<string, Part[] | undefined>,
+    }
+
+    applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
+
+    expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
+    expect(draft.part.msg_1).toBeUndefined()
+    expect(draft.part.msg_2).toHaveLength(1)
+  })
+})

+ 66 - 9
packages/app/src/context/sync.tsx

@@ -11,6 +11,43 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
 
 const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
 
+type OptimisticStore = {
+  message: Record<string, Message[] | undefined>
+  part: Record<string, Part[] | undefined>
+}
+
+type OptimisticAddInput = {
+  sessionID: string
+  message: Message
+  parts: Part[]
+}
+
+type OptimisticRemoveInput = {
+  sessionID: string
+  messageID: string
+}
+
+export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
+  const messages = draft.message[input.sessionID]
+  if (!messages) {
+    draft.message[input.sessionID] = [input.message]
+  }
+  if (messages) {
+    const result = Binary.search(messages, input.message.id, (m) => m.id)
+    messages.splice(result.index, 0, input.message)
+  }
+  draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+}
+
+export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
+  const messages = draft.message[input.sessionID]
+  if (messages) {
+    const result = Binary.search(messages, input.messageID, (m) => m.id)
+    if (result.found) messages.splice(result.index, 1)
+  }
+  delete draft.part[input.messageID]
+}
+
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
   init: () => {
@@ -21,6 +58,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     type Setter = Child[1]
 
     const current = createMemo(() => globalSync.child(sdk.directory))
+    const target = (directory?: string) => {
+      if (!directory || directory === sdk.directory) return current()
+      return globalSync.child(directory)
+    }
     const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
     const chunk = 400
     const inflight = new Map<string, Promise<void>>()
@@ -107,6 +148,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       },
       session: {
         get: getSession,
+        optimistic: {
+          add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+            const [, setStore] = target(input.directory)
+            setStore(
+              produce((draft) => {
+                applyOptimisticAdd(draft as OptimisticStore, input)
+              }),
+            )
+          },
+          remove(input: { directory?: string; sessionID: string; messageID: string }) {
+            const [, setStore] = target(input.directory)
+            setStore(
+              produce((draft) => {
+                applyOptimisticRemove(draft as OptimisticStore, input)
+              }),
+            )
+          },
+        },
         addOptimisticMessage(input: {
           sessionID: string
           messageID: string
@@ -122,16 +181,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             agent: input.agent,
             model: input.model,
           }
-          current()[1](
+          const [, setStore] = target()
+          setStore(
             produce((draft) => {
-              const messages = draft.message[input.sessionID]
-              if (!messages) {
-                draft.message[input.sessionID] = [message]
-              } else {
-                const result = Binary.search(messages, input.messageID, (m) => m.id)
-                messages.splice(result.index, 0, message)
-              }
-              draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
+              applyOptimisticAdd(draft as OptimisticStore, {
+                sessionID: input.sessionID,
+                message,
+                parts: input.parts,
+              })
             }),
           )
         },

+ 2 - 2
packages/app/src/i18n/ar.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "فتح الإعدادات",
   "command.session.previous": "الجلسة السابقة",
   "command.session.next": "الجلسة التالية",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "الجلسة غير المقروءة السابقة",
+  "command.session.next.unseen": "الجلسة غير المقروءة التالية",
   "command.session.archive": "أرشفة الجلسة",
 
   "command.palette": "لوحة الأوامر",

+ 2 - 2
packages/app/src/i18n/br.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "Abrir configurações",
   "command.session.previous": "Sessão anterior",
   "command.session.next": "Próxima sessão",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Sessão não lida anterior",
+  "command.session.next.unseen": "Próxima sessão não lida",
   "command.session.archive": "Arquivar sessão",
 
   "command.palette": "Paleta de comandos",

+ 2 - 2
packages/app/src/i18n/da.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "Åbn indstillinger",
   "command.session.previous": "Forrige session",
   "command.session.next": "Næste session",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Forrige ulæste session",
+  "command.session.next.unseen": "Næste ulæste session",
   "command.session.archive": "Arkivér session",
 
   "command.palette": "Kommandopalette",

+ 41 - 2
packages/app/src/i18n/de.ts

@@ -32,8 +32,8 @@ export const dict = {
   "command.settings.open": "Einstellungen öffnen",
   "command.session.previous": "Vorherige Sitzung",
   "command.session.next": "Nächste Sitzung",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Vorherige ungelesene Sitzung",
+  "command.session.next.unseen": "Nächste ungelesene Sitzung",
   "command.session.archive": "Sitzung archivieren",
 
   "command.palette": "Befehlspalette",
@@ -147,6 +147,44 @@ export const dict = {
   "provider.connect.toast.connected.title": "{{provider}} verbunden",
   "provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.",
 
+  "provider.custom.title": "Benutzerdefinierter Anbieter",
+  "provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ",
+  "provider.custom.description.link": "Anbieter-Konfigurationsdokumente",
+  "provider.custom.description.suffix": ".",
+  "provider.custom.field.providerID.label": "Anbieter-ID",
+  "provider.custom.field.providerID.placeholder": "myprovider",
+  "provider.custom.field.providerID.description": "Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
+  "provider.custom.field.name.label": "Anzeigename",
+  "provider.custom.field.name.placeholder": "Mein KI-Anbieter",
+  "provider.custom.field.baseURL.label": "Basis-URL",
+  "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+  "provider.custom.field.apiKey.label": "API-Schlüssel",
+  "provider.custom.field.apiKey.placeholder": "API-Schlüssel",
+  "provider.custom.field.apiKey.description":
+    "Optional. Leer lassen, wenn Sie die Authentifizierung über Header verwalten.",
+  "provider.custom.models.label": "Modelle",
+  "provider.custom.models.id.label": "ID",
+  "provider.custom.models.id.placeholder": "model-id",
+  "provider.custom.models.name.label": "Name",
+  "provider.custom.models.name.placeholder": "Anzeigename",
+  "provider.custom.models.remove": "Modell entfernen",
+  "provider.custom.models.add": "Modell hinzufügen",
+  "provider.custom.headers.label": "Header (optional)",
+  "provider.custom.headers.key.label": "Header",
+  "provider.custom.headers.key.placeholder": "Header-Name",
+  "provider.custom.headers.value.label": "Wert",
+  "provider.custom.headers.value.placeholder": "wert",
+  "provider.custom.headers.remove": "Header entfernen",
+  "provider.custom.headers.add": "Header hinzufügen",
+  "provider.custom.error.providerID.required": "Anbieter-ID ist erforderlich",
+  "provider.custom.error.providerID.format": "Verwenden Sie Kleinbuchstaben, Zahlen, Bindestriche oder Unterstriche",
+  "provider.custom.error.providerID.exists": "Diese Anbieter-ID existiert bereits",
+  "provider.custom.error.name.required": "Anzeigename ist erforderlich",
+  "provider.custom.error.baseURL.required": "Basis-URL ist erforderlich",
+  "provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen",
+  "provider.custom.error.required": "Erforderlich",
+  "provider.custom.error.duplicate": "Duplikat",
+
   "provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
   "provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
   "model.tag.free": "Kostenlos",
@@ -380,6 +418,7 @@ export const dict = {
     "Wurzelelement nicht gefunden. Haben Sie vergessen, es in Ihre index.html aufzunehmen? Oder wurde das id-Attribut falsch geschrieben?",
 
   "error.globalSync.connectFailed": "Verbindung zum Server fehlgeschlagen. Läuft ein Server unter `{{url}}`?",
+  "directory.error.invalidUrl": "Ungültiges Verzeichnis in der URL.",
 
   "error.chain.unknown": "Unbekannter Fehler",
   "error.chain.causedBy": "Verursacht durch:",

+ 38 - 0
packages/app/src/i18n/en.ts

@@ -149,6 +149,43 @@ export const dict = {
   "provider.connect.toast.connected.title": "{{provider}} connected",
   "provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
 
+  "provider.custom.title": "Custom provider",
+  "provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ",
+  "provider.custom.description.link": "provider config docs",
+  "provider.custom.description.suffix": ".",
+  "provider.custom.field.providerID.label": "Provider ID",
+  "provider.custom.field.providerID.placeholder": "myprovider",
+  "provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores",
+  "provider.custom.field.name.label": "Display name",
+  "provider.custom.field.name.placeholder": "My AI Provider",
+  "provider.custom.field.baseURL.label": "Base URL",
+  "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+  "provider.custom.field.apiKey.label": "API key",
+  "provider.custom.field.apiKey.placeholder": "API key",
+  "provider.custom.field.apiKey.description": "Optional. Leave empty if you manage auth via headers.",
+  "provider.custom.models.label": "Models",
+  "provider.custom.models.id.label": "ID",
+  "provider.custom.models.id.placeholder": "model-id",
+  "provider.custom.models.name.label": "Name",
+  "provider.custom.models.name.placeholder": "Display Name",
+  "provider.custom.models.remove": "Remove model",
+  "provider.custom.models.add": "Add model",
+  "provider.custom.headers.label": "Headers (optional)",
+  "provider.custom.headers.key.label": "Header",
+  "provider.custom.headers.key.placeholder": "Header-Name",
+  "provider.custom.headers.value.label": "Value",
+  "provider.custom.headers.value.placeholder": "value",
+  "provider.custom.headers.remove": "Remove header",
+  "provider.custom.headers.add": "Add header",
+  "provider.custom.error.providerID.required": "Provider ID is required",
+  "provider.custom.error.providerID.format": "Use lowercase letters, numbers, hyphens, or underscores",
+  "provider.custom.error.providerID.exists": "That provider ID already exists",
+  "provider.custom.error.name.required": "Display name is required",
+  "provider.custom.error.baseURL.required": "Base URL is required",
+  "provider.custom.error.baseURL.format": "Must start with http:// or https://",
+  "provider.custom.error.required": "Required",
+  "provider.custom.error.duplicate": "Duplicate",
+
   "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected",
   "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
 
@@ -404,6 +441,7 @@ export const dict = {
     "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
 
   "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?",
+  "directory.error.invalidUrl": "Invalid directory in URL.",
 
   "error.chain.unknown": "Unknown error",
   "error.chain.causedBy": "Caused by:",

+ 2 - 2
packages/app/src/i18n/es.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "Abrir ajustes",
   "command.session.previous": "Sesión anterior",
   "command.session.next": "Siguiente sesión",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Sesión no leída anterior",
+  "command.session.next.unseen": "Siguiente sesión no leída",
   "command.session.archive": "Archivar sesión",
 
   "command.palette": "Paleta de comandos",

+ 2 - 2
packages/app/src/i18n/fr.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "Ouvrir les paramètres",
   "command.session.previous": "Session précédente",
   "command.session.next": "Session suivante",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Session non lue précédente",
+  "command.session.next.unseen": "Session non lue suivante",
   "command.session.archive": "Archiver la session",
 
   "command.palette": "Palette de commandes",

+ 2 - 2
packages/app/src/i18n/ja.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "設定を開く",
   "command.session.previous": "前のセッション",
   "command.session.next": "次のセッション",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "前の未読セッション",
+  "command.session.next.unseen": "次の未読セッション",
   "command.session.archive": "セッションをアーカイブ",
 
   "command.palette": "コマンドパレット",

+ 2 - 2
packages/app/src/i18n/ko.ts

@@ -32,8 +32,8 @@ export const dict = {
   "command.settings.open": "설정 열기",
   "command.session.previous": "이전 세션",
   "command.session.next": "다음 세션",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "이전 읽지 않은 세션",
+  "command.session.next.unseen": "다음 읽지 않은 세션",
   "command.session.archive": "세션 보관",
 
   "command.palette": "명령 팔레트",

+ 2 - 2
packages/app/src/i18n/no.ts

@@ -31,8 +31,8 @@ export const dict = {
   "command.settings.open": "Åpne innstillinger",
   "command.session.previous": "Forrige sesjon",
   "command.session.next": "Neste sesjon",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Forrige uleste økt",
+  "command.session.next.unseen": "Neste uleste økt",
   "command.session.archive": "Arkiver sesjon",
 
   "command.palette": "Kommandopalett",

+ 31 - 0
packages/app/src/i18n/parity.test.ts

@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test"
+import { dict as en } from "./en"
+import { dict as ar } from "./ar"
+import { dict as br } from "./br"
+import { dict as bs } from "./bs"
+import { dict as da } from "./da"
+import { dict as de } from "./de"
+import { dict as es } from "./es"
+import { dict as fr } from "./fr"
+import { dict as ja } from "./ja"
+import { dict as ko } from "./ko"
+import { dict as no } from "./no"
+import { dict as pl } from "./pl"
+import { dict as ru } from "./ru"
+import { dict as th } from "./th"
+import { dict as zh } from "./zh"
+import { dict as zht } from "./zht"
+
+const locales = [ar, br, bs, da, de, es, fr, ja, ko, no, pl, ru, th, zh, zht]
+const keys = ["command.session.previous.unseen", "command.session.next.unseen"] as const
+
+describe("i18n parity", () => {
+  test("non-English locales translate targeted unseen session keys", () => {
+    for (const locale of locales) {
+      for (const key of keys) {
+        expect(locale[key]).toBeDefined()
+        expect(locale[key]).not.toBe(en[key])
+      }
+    }
+  })
+})

+ 2 - 2
packages/app/src/i18n/pl.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "Otwórz ustawienia",
   "command.session.previous": "Poprzednia sesja",
   "command.session.next": "Następna sesja",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Poprzednia nieprzeczytana sesja",
+  "command.session.next.unseen": "Następna nieprzeczytana sesja",
   "command.session.archive": "Zarchiwizuj sesję",
 
   "command.palette": "Paleta poleceń",

+ 2 - 2
packages/app/src/i18n/ru.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "Открыть настройки",
   "command.session.previous": "Предыдущая сессия",
   "command.session.next": "Следующая сессия",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "Предыдущая непрочитанная сессия",
+  "command.session.next.unseen": "Следующая непрочитанная сессия",
   "command.session.archive": "Архивировать сессию",
 
   "command.palette": "Палитра команд",

+ 2 - 2
packages/app/src/i18n/th.ts

@@ -28,8 +28,8 @@ export const dict = {
   "command.settings.open": "เปิดการตั้งค่า",
   "command.session.previous": "เซสชันก่อนหน้า",
   "command.session.next": "เซสชันถัดไป",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "เซสชันที่ยังไม่ได้อ่านก่อนหน้า",
+  "command.session.next.unseen": "เซสชันที่ยังไม่ได้อ่านถัดไป",
   "command.session.archive": "จัดเก็บเซสชัน",
 
   "command.palette": "คำสั่งค้นหา",

+ 40 - 2
packages/app/src/i18n/zh.ts

@@ -32,8 +32,8 @@ export const dict = {
   "command.settings.open": "打开设置",
   "command.session.previous": "上一个会话",
   "command.session.next": "下一个会话",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "上一个未读会话",
+  "command.session.next.unseen": "下一个未读会话",
   "command.session.archive": "归档会话",
 
   "command.palette": "命令面板",
@@ -147,6 +147,43 @@ export const dict = {
   "provider.connect.toast.connected.title": "{{provider}} 已连接",
   "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。",
 
+  "provider.custom.title": "自定义提供商",
+  "provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看",
+  "provider.custom.description.link": "提供商配置文档",
+  "provider.custom.description.suffix": "。",
+  "provider.custom.field.providerID.label": "提供商 ID",
+  "provider.custom.field.providerID.placeholder": "myprovider",
+  "provider.custom.field.providerID.description": "使用小写字母、数字、连字符或下划线",
+  "provider.custom.field.name.label": "显示名称",
+  "provider.custom.field.name.placeholder": "我的 AI 提供商",
+  "provider.custom.field.baseURL.label": "基础 URL",
+  "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+  "provider.custom.field.apiKey.label": "API 密钥",
+  "provider.custom.field.apiKey.placeholder": "API 密钥",
+  "provider.custom.field.apiKey.description": "可选。如果你通过请求头管理认证,可留空。",
+  "provider.custom.models.label": "模型",
+  "provider.custom.models.id.label": "ID",
+  "provider.custom.models.id.placeholder": "model-id",
+  "provider.custom.models.name.label": "名称",
+  "provider.custom.models.name.placeholder": "显示名称",
+  "provider.custom.models.remove": "移除模型",
+  "provider.custom.models.add": "添加模型",
+  "provider.custom.headers.label": "请求头(可选)",
+  "provider.custom.headers.key.label": "请求头",
+  "provider.custom.headers.key.placeholder": "Header-Name",
+  "provider.custom.headers.value.label": "值",
+  "provider.custom.headers.value.placeholder": "value",
+  "provider.custom.headers.remove": "移除请求头",
+  "provider.custom.headers.add": "添加请求头",
+  "provider.custom.error.providerID.required": "提供商 ID 为必填项",
+  "provider.custom.error.providerID.format": "请使用小写字母、数字、连字符或下划线",
+  "provider.custom.error.providerID.exists": "该提供商 ID 已存在",
+  "provider.custom.error.name.required": "显示名称为必填项",
+  "provider.custom.error.baseURL.required": "基础 URL 为必填项",
+  "provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头",
+  "provider.custom.error.required": "必填",
+  "provider.custom.error.duplicate": "重复",
+
   "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
   "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
   "model.tag.free": "免费",
@@ -380,6 +417,7 @@ export const dict = {
   "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
 
   "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
+  "directory.error.invalidUrl": "URL 中的目录无效。",
 
   "error.chain.unknown": "未知错误",
   "error.chain.causedBy": "原因:",

+ 40 - 2
packages/app/src/i18n/zht.ts

@@ -32,8 +32,8 @@ export const dict = {
   "command.settings.open": "開啟設定",
   "command.session.previous": "上一個工作階段",
   "command.session.next": "下一個工作階段",
-  "command.session.previous.unseen": "Previous unread session",
-  "command.session.next.unseen": "Next unread session",
+  "command.session.previous.unseen": "上一個未讀會話",
+  "command.session.next.unseen": "下一個未讀會話",
   "command.session.archive": "封存工作階段",
 
   "command.palette": "命令面板",
@@ -144,6 +144,43 @@ export const dict = {
   "provider.connect.toast.connected.title": "{{provider}} 已連線",
   "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。",
 
+  "provider.custom.title": "自訂提供商",
+  "provider.custom.description.prefix": "設定與 OpenAI 相容的提供商。請參閱",
+  "provider.custom.description.link": "提供商設定文件",
+  "provider.custom.description.suffix": "。",
+  "provider.custom.field.providerID.label": "提供商 ID",
+  "provider.custom.field.providerID.placeholder": "myprovider",
+  "provider.custom.field.providerID.description": "使用小寫字母、數字、連字號或底線",
+  "provider.custom.field.name.label": "顯示名稱",
+  "provider.custom.field.name.placeholder": "我的 AI 提供商",
+  "provider.custom.field.baseURL.label": "基礎 URL",
+  "provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
+  "provider.custom.field.apiKey.label": "API 金鑰",
+  "provider.custom.field.apiKey.placeholder": "API 金鑰",
+  "provider.custom.field.apiKey.description": "選填。若您透過標頭管理驗證,可留空。",
+  "provider.custom.models.label": "模型",
+  "provider.custom.models.id.label": "ID",
+  "provider.custom.models.id.placeholder": "model-id",
+  "provider.custom.models.name.label": "名稱",
+  "provider.custom.models.name.placeholder": "顯示名稱",
+  "provider.custom.models.remove": "移除模型",
+  "provider.custom.models.add": "新增模型",
+  "provider.custom.headers.label": "標頭(選填)",
+  "provider.custom.headers.key.label": "標頭",
+  "provider.custom.headers.key.placeholder": "Header-Name",
+  "provider.custom.headers.value.label": "值",
+  "provider.custom.headers.value.placeholder": "value",
+  "provider.custom.headers.remove": "移除標頭",
+  "provider.custom.headers.add": "新增標頭",
+  "provider.custom.error.providerID.required": "提供商 ID 為必填",
+  "provider.custom.error.providerID.format": "請使用小寫字母、數字、連字號或底線",
+  "provider.custom.error.providerID.exists": "該提供商 ID 已存在",
+  "provider.custom.error.name.required": "顯示名稱為必填",
+  "provider.custom.error.baseURL.required": "基礎 URL 為必填",
+  "provider.custom.error.baseURL.format": "必須以 http:// 或 https:// 開頭",
+  "provider.custom.error.required": "必填",
+  "provider.custom.error.duplicate": "重複",
+
   "provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線",
   "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
   "model.tag.free": "免費",
@@ -377,6 +414,7 @@ export const dict = {
   "error.dev.rootNotFound": "找不到根元素。你是不是忘了把它新增到 index.html? 或者 id 屬性拼錯了?",
 
   "error.globalSync.connectFailed": "無法連線到伺服器。是否有伺服器正在 `{{url}}` 執行?",
+  "directory.error.invalidUrl": "URL 中的目錄無效。",
 
   "error.chain.unknown": "未知錯誤",
   "error.chain.causedBy": "原因:",

+ 1 - 1
packages/app/src/pages/directory-layout.tsx

@@ -25,7 +25,7 @@ export default function Layout(props: ParentProps) {
     showToast({
       variant: "error",
       title: language.t("common.requestFailed"),
-      description: "Invalid directory in URL.",
+      description: language.t("directory.error.invalidUrl"),
     })
     navigate("/")
   })

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 51 - 1033
packages/app/src/pages/layout.tsx


+ 26 - 0
packages/app/src/pages/layout/deep-links.ts

@@ -0,0 +1,26 @@
+export const deepLinkEvent = "opencode:deep-link"
+
+export const parseDeepLink = (input: string) => {
+  if (!input.startsWith("opencode://")) return
+  const url = new URL(input)
+  if (url.hostname !== "open-project") return
+  const directory = url.searchParams.get("directory")
+  if (!directory) return
+  return directory
+}
+
+export const collectOpenProjectDeepLinks = (urls: string[]) =>
+  urls.map(parseDeepLink).filter((directory): directory is string => !!directory)
+
+type OpenCodeWindow = Window & {
+  __OPENCODE__?: {
+    deepLinks?: string[]
+  }
+}
+
+export const drainPendingDeepLinks = (target: OpenCodeWindow) => {
+  const pending = target.__OPENCODE__?.deepLinks ?? []
+  if (pending.length === 0) return []
+  if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = []
+  return pending
+}

+ 63 - 0
packages/app/src/pages/layout/helpers.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
+import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
+
+describe("layout deep links", () => {
+  test("parses open-project deep links", () => {
+    expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo")
+  })
+
+  test("ignores non-project deep links", () => {
+    expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined()
+    expect(parseDeepLink("https://example.com")).toBeUndefined()
+  })
+
+  test("collects only valid open-project directories", () => {
+    const result = collectOpenProjectDeepLinks([
+      "opencode://open-project?directory=/a",
+      "opencode://other?directory=/b",
+      "opencode://open-project?directory=/c",
+    ])
+    expect(result).toEqual(["/a", "/c"])
+  })
+
+  test("drains global deep links once", () => {
+    const target = {
+      __OPENCODE__: {
+        deepLinks: ["opencode://open-project?directory=/a"],
+      },
+    } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } }
+
+    expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"])
+    expect(drainPendingDeepLinks(target)).toEqual([])
+  })
+})
+
+describe("layout workspace helpers", () => {
+  test("normalizes trailing slash in workspace key", () => {
+    expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
+    expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
+  })
+
+  test("keeps local first while preserving known order", () => {
+    const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"])
+    expect(result).toEqual(["/root", "/c", "/b"])
+  })
+
+  test("extracts draggable id safely", () => {
+    expect(getDraggableId({ draggable: { id: "x" } })).toBe("x")
+    expect(getDraggableId({ draggable: { id: 42 } })).toBeUndefined()
+    expect(getDraggableId(null)).toBeUndefined()
+  })
+
+  test("formats fallback project display name", () => {
+    expect(displayName({ worktree: "/tmp/app" })).toBe("app")
+    expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
+  })
+
+  test("extracts api error message and fallback", () => {
+    expect(errorMessage({ data: { message: "boom" } }, "fallback")).toBe("boom")
+    expect(errorMessage(new Error("broken"), "fallback")).toBe("broken")
+    expect(errorMessage("unknown", "fallback")).toBe("fallback")
+  })
+})

+ 65 - 0
packages/app/src/pages/layout/helpers.ts

@@ -0,0 +1,65 @@
+import { getFilename } from "@opencode-ai/util/path"
+import { type Session } from "@opencode-ai/sdk/v2/client"
+
+export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
+
+export function sortSessions(now: number) {
+  const oneMinuteAgo = now - 60 * 1000
+  return (a: Session, b: Session) => {
+    const aUpdated = a.time.updated ?? a.time.created
+    const bUpdated = b.time.updated ?? b.time.created
+    const aRecent = aUpdated > oneMinuteAgo
+    const bRecent = bUpdated > oneMinuteAgo
+    if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
+    if (aRecent && !bRecent) return -1
+    if (!aRecent && bRecent) return 1
+    return bUpdated - aUpdated
+  }
+}
+
+export const isRootVisibleSession = (session: Session, directory: string) =>
+  workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
+
+export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
+  store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
+
+export const childMapByParent = (sessions: Session[]) => {
+  const map = new Map<string, string[]>()
+  for (const session of sessions) {
+    if (!session.parentID) continue
+    const existing = map.get(session.parentID)
+    if (existing) {
+      existing.push(session.id)
+      continue
+    }
+    map.set(session.parentID, [session.id])
+  }
+  return map
+}
+
+export function 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
+}
+
+export const displayName = (project: { name?: string; worktree: string }) =>
+  project.name || getFilename(project.worktree)
+
+export const errorMessage = (err: unknown, fallback: string) => {
+  if (err && typeof err === "object" && "data" in err) {
+    const data = (err as { data?: { message?: string } }).data
+    if (data?.message) return data.message
+  }
+  if (err instanceof Error) return err.message
+  return fallback
+}
+
+export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
+  if (!existing) return dirs
+  const keep = existing.filter((d) => d !== local && dirs.includes(d))
+  const missing = dirs.filter((d) => d !== local && !existing.includes(d))
+  return [local, ...missing, ...keep]
+}

+ 113 - 0
packages/app/src/pages/layout/inline-editor.tsx

@@ -0,0 +1,113 @@
+import { createStore } from "solid-js/store"
+import { Show, type Accessor } from "solid-js"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+
+export function createInlineEditorController() {
+  const [editor, setEditor] = createStore({
+    active: "" as string,
+    value: "",
+  })
+
+  const editorOpen = (id: string) => editor.active === id
+  const editorValue = () => editor.value
+  const openEditor = (id: string, value: string) => {
+    if (!id) return
+    setEditor({ active: id, value })
+  }
+  const closeEditor = () => setEditor({ active: "", value: "" })
+
+  const saveEditor = (callback: (next: string) => void) => {
+    const next = editor.value.trim()
+    if (!next) {
+      closeEditor()
+      return
+    }
+    closeEditor()
+    callback(next)
+  }
+
+  const editorKeyDown = (event: KeyboardEvent, callback: (next: string) => void) => {
+    if (event.key === "Enter") {
+      event.preventDefault()
+      saveEditor(callback)
+      return
+    }
+    if (event.key !== "Escape") return
+    event.preventDefault()
+    closeEditor()
+  }
+
+  const InlineEditor = (props: {
+    id: string
+    value: Accessor<string>
+    onSave: (next: string) => void
+    class?: string
+    displayClass?: string
+    editing?: boolean
+    stopPropagation?: boolean
+    openOnDblClick?: boolean
+  }) => {
+    const isEditing = () => props.editing ?? editorOpen(props.id)
+    const stopEvents = () => props.stopPropagation ?? false
+    const allowDblClick = () => props.openOnDblClick ?? true
+    const stopPropagation = (event: Event) => {
+      if (!stopEvents()) return
+      event.stopPropagation()
+    }
+    const handleDblClick = (event: MouseEvent) => {
+      if (!allowDblClick()) return
+      stopPropagation(event)
+      openEditor(props.id, props.value())
+    }
+
+    return (
+      <Show
+        when={isEditing()}
+        fallback={
+          <span
+            class={props.displayClass ?? props.class}
+            onDblClick={handleDblClick}
+            onPointerDown={stopPropagation}
+            onMouseDown={stopPropagation}
+            onClick={stopPropagation}
+            onTouchStart={stopPropagation}
+          >
+            {props.value()}
+          </span>
+        }
+      >
+        <InlineInput
+          ref={(el) => {
+            requestAnimationFrame(() => el.focus())
+          }}
+          value={editorValue()}
+          class={props.class}
+          onInput={(event) => setEditor("value", event.currentTarget.value)}
+          onKeyDown={(event) => {
+            event.stopPropagation()
+            editorKeyDown(event, props.onSave)
+          }}
+          onBlur={closeEditor}
+          onPointerDown={stopPropagation}
+          onClick={stopPropagation}
+          onDblClick={stopPropagation}
+          onMouseDown={stopPropagation}
+          onMouseUp={stopPropagation}
+          onTouchStart={stopPropagation}
+        />
+      </Show>
+    )
+  }
+
+  return {
+    editor,
+    editorOpen,
+    editorValue,
+    openEditor,
+    closeEditor,
+    saveEditor,
+    editorKeyDown,
+    setEditor,
+    InlineEditor,
+  }
+}

+ 330 - 0
packages/app/src/pages/layout/sidebar-items.tsx

@@ -0,0 +1,330 @@
+import { A, useNavigate, useParams } from "@solidjs/router"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout"
+import { useNotification } from "@/context/notification"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Avatar } from "@opencode-ai/ui/avatar"
+import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { MessageNav } from "@opencode-ai/ui/message-nav"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getFilename } from "@opencode-ai/util/path"
+import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/client"
+import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js"
+import { agentColor } from "@/utils/agent"
+
+const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+
+export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
+  const notification = useNotification()
+  const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree))
+  const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree))
+  const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
+  return (
+    <div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
+      <div class="size-full rounded overflow-clip">
+        <Avatar
+          fallback={name()}
+          src={
+            props.project.id === OPENCODE_PROJECT_ID ? "https://opencode.ai/favicon.svg" : props.project.icon?.override
+          }
+          {...getAvatarColors(props.project.icon?.color)}
+          class="size-full rounded"
+          classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
+        />
+      </div>
+      <Show when={unseenCount() > 0 && props.notify}>
+        <div
+          classList={{
+            "absolute top-px right-px size-1.5 rounded-full z-10": true,
+            "bg-icon-critical-base": hasError(),
+            "bg-text-interactive-base": !hasError(),
+          }}
+        />
+      </Show>
+    </div>
+  )
+}
+
+export type SessionItemProps = {
+  session: Session
+  slug: string
+  mobile?: boolean
+  dense?: boolean
+  popover?: boolean
+  children: Map<string, string[]>
+  sidebarExpanded: Accessor<boolean>
+  sidebarHovering: Accessor<boolean>
+  nav: Accessor<HTMLElement | undefined>
+  hoverSession: Accessor<string | undefined>
+  setHoverSession: (id: string | undefined) => void
+  clearHoverProjectSoon: () => void
+  prefetchSession: (session: Session, priority?: "high" | "low") => void
+  archiveSession: (session: Session) => Promise<void>
+}
+
+export const SessionItem = (props: SessionItemProps): JSX.Element => {
+  const params = useParams()
+  const navigate = useNavigate()
+  const layout = useLayout()
+  const language = useLanguage()
+  const notification = useNotification()
+  const globalSync = useGlobalSync()
+  const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id))
+  const hasError = createMemo(() => notification.session.unseenHasError(props.session.id))
+  const [sessionStore] = globalSync.child(props.session.directory)
+  const hasPermissions = createMemo(() => {
+    const permissions = sessionStore.permission?.[props.session.id] ?? []
+    if (permissions.length > 0) return true
+
+    for (const id of props.children.get(props.session.id) ?? []) {
+      const childPermissions = sessionStore.permission?.[id] ?? []
+      if (childPermissions.length > 0) return true
+    }
+    return false
+  })
+  const isWorking = createMemo(() => {
+    if (hasPermissions()) return false
+    const status = sessionStore.session_status[props.session.id]
+    return status?.type === "busy" || status?.type === "retry"
+  })
+
+  const tint = createMemo(() => {
+    const messages = sessionStore.message[props.session.id]
+    if (!messages) return undefined
+    let user: Message | undefined
+    for (let i = messages.length - 1; i >= 0; i--) {
+      const message = messages[i]
+      if (message.role !== "user") continue
+      user = message
+      break
+    }
+    if (!user?.agent) return undefined
+
+    const agent = sessionStore.agent.find((a) => a.name === user.agent)
+    return agentColor(user.agent, agent?.color)
+  })
+
+  const hoverMessages = createMemo(() =>
+    sessionStore.message[props.session.id]?.filter((message) => message.role === "user"),
+  )
+  const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined)
+  const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
+  const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
+  const isActive = createMemo(() => props.session.id === params.id)
+
+  const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
+  const cancelHoverPrefetch = () => {
+    if (hoverPrefetch.current === undefined) return
+    clearTimeout(hoverPrefetch.current)
+    hoverPrefetch.current = undefined
+  }
+  const scheduleHoverPrefetch = () => {
+    if (hoverPrefetch.current !== undefined) return
+    hoverPrefetch.current = setTimeout(() => {
+      hoverPrefetch.current = undefined
+      props.prefetchSession(props.session)
+    }, 200)
+  }
+
+  onCleanup(cancelHoverPrefetch)
+
+  const messageLabel = (message: Message) => {
+    const parts = sessionStore.part[message.id] ?? []
+    const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
+    return text?.text
+  }
+
+  const item = (
+    <A
+      href={`${props.slug}/session/${props.session.id}`}
+      class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
+      onPointerEnter={scheduleHoverPrefetch}
+      onPointerLeave={cancelHoverPrefetch}
+      onMouseEnter={scheduleHoverPrefetch}
+      onMouseLeave={cancelHoverPrefetch}
+      onFocus={() => props.prefetchSession(props.session, "high")}
+      onClick={() => {
+        props.setHoverSession(undefined)
+        if (layout.sidebar.opened()) return
+        props.clearHoverProjectSoon()
+      }}
+    >
+      <div class="flex items-center gap-1 w-full">
+        <div
+          class="shrink-0 size-6 flex items-center justify-center"
+          style={{ color: tint() ?? "var(--icon-interactive-base)" }}
+        >
+          <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
+            <Match when={isWorking()}>
+              <Spinner class="size-[15px]" />
+            </Match>
+            <Match when={hasPermissions()}>
+              <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+            </Match>
+            <Match when={hasError()}>
+              <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+            </Match>
+            <Match when={unseenCount() > 0}>
+              <div class="size-1.5 rounded-full bg-text-interactive-base" />
+            </Match>
+          </Switch>
+        </div>
+        <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+          {props.session.title}
+        </span>
+        <Show when={props.session.summary}>
+          {(summary) => (
+            <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+              <DiffChanges changes={summary()} />
+            </div>
+          )}
+        </Show>
+      </div>
+    </A>
+  )
+
+  return (
+    <div
+      data-session-id={props.session.id}
+      class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
+             hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
+    >
+      <Show
+        when={hoverEnabled()}
+        fallback={
+          <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
+            {item}
+          </Tooltip>
+        }
+      >
+        <HoverCard
+          openDelay={1000}
+          closeDelay={props.sidebarHovering() ? 600 : 0}
+          placement="right-start"
+          gutter={16}
+          shift={-2}
+          trigger={item}
+          mount={!props.mobile ? props.nav() : undefined}
+          open={props.hoverSession() === props.session.id}
+          onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)}
+        >
+          <Show
+            when={hoverReady()}
+            fallback={<div class="text-12-regular text-text-weak">{language.t("session.messages.loading")}</div>}
+          >
+            <div class="overflow-y-auto max-h-72 h-full">
+              <MessageNav
+                messages={hoverMessages() ?? []}
+                current={undefined}
+                getLabel={messageLabel}
+                onMessageSelect={(message) => {
+                  if (!isActive()) {
+                    layout.pendingMessage.set(
+                      `${base64Encode(props.session.directory)}/${props.session.id}`,
+                      message.id,
+                    )
+                    navigate(`${props.slug}/session/${props.session.id}`)
+                    return
+                  }
+                  window.history.replaceState(null, "", `#message-${message.id}`)
+                  window.dispatchEvent(new HashChangeEvent("hashchange"))
+                }}
+                size="normal"
+                class="w-60"
+              />
+            </div>
+          </Show>
+        </HoverCard>
+      </Show>
+      <div
+        class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
+        classList={{
+          "opacity-100 pointer-events-auto": !!props.mobile,
+          "opacity-0 pointer-events-none": !props.mobile,
+          "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
+          "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
+        }}
+      >
+        <Tooltip value={language.t("common.archive")} placement="top">
+          <IconButton
+            icon="archive"
+            variant="ghost"
+            class="size-6 rounded-md"
+            aria-label={language.t("common.archive")}
+            onClick={(event) => {
+              event.preventDefault()
+              event.stopPropagation()
+              void props.archiveSession(props.session)
+            }}
+          />
+        </Tooltip>
+      </div>
+    </div>
+  )
+}
+
+export const NewSessionItem = (props: {
+  slug: string
+  mobile?: boolean
+  dense?: boolean
+  sidebarExpanded: Accessor<boolean>
+  clearHoverProjectSoon: () => void
+  setHoverSession: (id: string | undefined) => void
+}): JSX.Element => {
+  const layout = useLayout()
+  const language = useLanguage()
+  const label = language.t("command.session.new")
+  const tooltip = () => props.mobile || !props.sidebarExpanded()
+  const item = (
+    <A
+      href={`${props.slug}/session`}
+      end
+      class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
+      onClick={() => {
+        props.setHoverSession(undefined)
+        if (layout.sidebar.opened()) return
+        props.clearHoverProjectSoon()
+      }}
+    >
+      <div class="flex items-center gap-1 w-full">
+        <div class="shrink-0 size-6 flex items-center justify-center">
+          <Icon name="plus-small" size="small" class="text-icon-weak" />
+        </div>
+        <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
+          {label}
+        </span>
+      </div>
+    </A>
+  )
+
+  return (
+    <div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
+      <Show
+        when={!tooltip()}
+        fallback={
+          <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}>
+            {item}
+          </Tooltip>
+        }
+      >
+        {item}
+      </Show>
+    </div>
+  )
+}
+
+export const SessionSkeleton = (props: { count?: number }): JSX.Element => {
+  const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
+  return (
+    <div class="flex flex-col gap-1">
+      <For each={items}>
+        {() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
+      </For>
+    </div>
+  )
+}

+ 63 - 0
packages/app/src/pages/layout/sidebar-project-helpers.test.ts

@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
+
+describe("projectSelected", () => {
+  test("matches direct worktree", () => {
+    expect(projectSelected("/tmp/root", "/tmp/root")).toBe(true)
+  })
+
+  test("matches sandbox worktree", () => {
+    expect(projectSelected("/tmp/branch", "/tmp/root", ["/tmp/branch"])).toBe(true)
+    expect(projectSelected("/tmp/other", "/tmp/root", ["/tmp/branch"])).toBe(false)
+  })
+})
+
+describe("projectTileActive", () => {
+  test("menu state always wins", () => {
+    expect(
+      projectTileActive({
+        menu: true,
+        preview: false,
+        open: false,
+        overlay: false,
+        worktree: "/tmp/root",
+      }),
+    ).toBe(true)
+  })
+
+  test("preview mode uses open state", () => {
+    expect(
+      projectTileActive({
+        menu: false,
+        preview: true,
+        open: true,
+        overlay: true,
+        hoverProject: "/tmp/other",
+        worktree: "/tmp/root",
+      }),
+    ).toBe(true)
+  })
+
+  test("overlay mode uses hovered project", () => {
+    expect(
+      projectTileActive({
+        menu: false,
+        preview: false,
+        open: false,
+        overlay: true,
+        hoverProject: "/tmp/root",
+        worktree: "/tmp/root",
+      }),
+    ).toBe(true)
+    expect(
+      projectTileActive({
+        menu: false,
+        preview: false,
+        open: false,
+        overlay: true,
+        hoverProject: "/tmp/other",
+        worktree: "/tmp/root",
+      }),
+    ).toBe(false)
+  })
+})

+ 11 - 0
packages/app/src/pages/layout/sidebar-project-helpers.ts

@@ -0,0 +1,11 @@
+export const projectSelected = (currentDir: string, worktree: string, sandboxes?: string[]) =>
+  worktree === currentDir || sandboxes?.includes(currentDir) === true
+
+export const projectTileActive = (args: {
+  menu: boolean
+  preview: boolean
+  open: boolean
+  overlay: boolean
+  hoverProject?: string
+  worktree: string
+}) => args.menu || (args.preview ? args.open : args.overlay && args.hoverProject === args.worktree)

+ 283 - 0
packages/app/src/pages/layout/sidebar-project.tsx

@@ -0,0 +1,283 @@
+import { createEffect, createMemo, createSignal, For, Show, type Accessor, type JSX } from "solid-js"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { Button } from "@opencode-ai/ui/button"
+import { ContextMenu } from "@opencode-ai/ui/context-menu"
+import { HoverCard } from "@opencode-ai/ui/hover-card"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { type LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
+import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
+import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
+
+export type ProjectSidebarContext = {
+  currentDir: Accessor<string>
+  sidebarOpened: Accessor<boolean>
+  sidebarHovering: Accessor<boolean>
+  hoverProject: Accessor<string | undefined>
+  nav: Accessor<HTMLElement | undefined>
+  onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
+  onProjectMouseLeave: (worktree: string) => void
+  onProjectFocus: (worktree: string) => void
+  navigateToProject: (directory: string) => void
+  openSidebar: () => void
+  closeProject: (directory: string) => void
+  showEditProjectDialog: (project: LocalProject) => void
+  toggleProjectWorkspaces: (project: LocalProject) => void
+  workspacesEnabled: (project: LocalProject) => boolean
+  workspaceIds: (project: LocalProject) => string[]
+  workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
+  sessionProps: Omit<SessionItemProps, "session" | "slug" | "children" | "mobile" | "dense" | "popover">
+  setHoverSession: (id: string | undefined) => void
+}
+
+export const ProjectDragOverlay = (props: {
+  projects: Accessor<LocalProject[]>
+  activeProject: Accessor<string | undefined>
+}): JSX.Element => {
+  const project = createMemo(() => props.projects().find((p) => p.worktree === props.activeProject()))
+  return (
+    <Show when={project()}>
+      {(p) => (
+        <div class="bg-background-base rounded-xl p-1">
+          <ProjectIcon project={p()} />
+        </div>
+      )}
+    </Show>
+  )
+}
+
+export const SortableProject = (props: {
+  project: LocalProject
+  mobile?: boolean
+  ctx: ProjectSidebarContext
+}): JSX.Element => {
+  const globalSync = useGlobalSync()
+  const language = useLanguage()
+  const sortable = createSortable(props.project.worktree)
+  const selected = createMemo(() =>
+    projectSelected(props.ctx.currentDir(), props.project.worktree, props.project.sandboxes),
+  )
+  const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
+  const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
+  const [open, setOpen] = createSignal(false)
+  const [menu, setMenu] = createSignal(false)
+
+  const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
+  const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
+  const active = createMemo(() =>
+    projectTileActive({
+      menu: menu(),
+      preview: preview(),
+      open: open(),
+      overlay: overlay(),
+      hoverProject: props.ctx.hoverProject(),
+      worktree: props.project.worktree,
+    }),
+  )
+
+  createEffect(() => {
+    if (preview()) return
+    if (!open()) return
+    setOpen(false)
+  })
+
+  const label = (directory: string) => {
+    const [data] = globalSync.child(directory, { bootstrap: false })
+    const kind =
+      directory === props.project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+    const name = props.ctx.workspaceLabel(directory, data.vcs?.branch, props.project.id)
+    return `${kind} : ${name}`
+  }
+
+  const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
+  const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2))
+  const projectChildren = createMemo(() => childMapByParent(projectStore().session))
+  const workspaceSessions = (directory: string) => {
+    const [data] = globalSync.child(directory, { bootstrap: false })
+    return sortedRootSessions(data, Date.now()).slice(0, 2)
+  }
+  const workspaceChildren = (directory: string) => {
+    const [data] = globalSync.child(directory, { bootstrap: false })
+    return childMapByParent(data.session)
+  }
+
+  const Trigger = () => (
+    <ContextMenu
+      modal={!props.ctx.sidebarHovering()}
+      onOpenChange={(value) => {
+        setMenu(value)
+        if (value) setOpen(false)
+      }}
+    >
+      <ContextMenu.Trigger
+        as="button"
+        type="button"
+        aria-label={displayName(props.project)}
+        data-action="project-switch"
+        data-project={base64Encode(props.project.worktree)}
+        classList={{
+          "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
+          "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
+          "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
+            !selected() && !active(),
+          "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+        }}
+        onMouseEnter={(event: MouseEvent) => {
+          if (!overlay()) return
+          props.ctx.onProjectMouseEnter(props.project.worktree, event)
+        }}
+        onMouseLeave={() => {
+          if (!overlay()) return
+          props.ctx.onProjectMouseLeave(props.project.worktree)
+        }}
+        onFocus={() => {
+          if (!overlay()) return
+          props.ctx.onProjectFocus(props.project.worktree)
+        }}
+        onClick={() => props.ctx.navigateToProject(props.project.worktree)}
+        onBlur={() => setOpen(false)}
+      >
+        <ProjectIcon project={props.project} notify />
+      </ContextMenu.Trigger>
+      <ContextMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
+        <ContextMenu.Content>
+          <ContextMenu.Item onSelect={() => props.ctx.showEditProjectDialog(props.project)}>
+            <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
+          </ContextMenu.Item>
+          <ContextMenu.Item
+            data-action="project-workspaces-toggle"
+            data-project={base64Encode(props.project.worktree)}
+            disabled={props.project.vcs !== "git" && !props.ctx.workspacesEnabled(props.project)}
+            onSelect={() => props.ctx.toggleProjectWorkspaces(props.project)}
+          >
+            <ContextMenu.ItemLabel>
+              {props.ctx.workspacesEnabled(props.project)
+                ? language.t("sidebar.workspaces.disable")
+                : language.t("sidebar.workspaces.enable")}
+            </ContextMenu.ItemLabel>
+          </ContextMenu.Item>
+          <ContextMenu.Separator />
+          <ContextMenu.Item
+            data-action="project-close-menu"
+            data-project={base64Encode(props.project.worktree)}
+            onSelect={() => props.ctx.closeProject(props.project.worktree)}
+          >
+            <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
+          </ContextMenu.Item>
+        </ContextMenu.Content>
+      </ContextMenu.Portal>
+    </ContextMenu>
+  )
+
+  return (
+    // @ts-ignore
+    <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
+      <Show when={preview()} fallback={<Trigger />}>
+        <HoverCard
+          open={open() && !menu()}
+          openDelay={0}
+          closeDelay={0}
+          placement="right-start"
+          gutter={6}
+          trigger={<Trigger />}
+          onOpenChange={(value) => {
+            if (menu()) return
+            setOpen(value)
+            if (value) props.ctx.setHoverSession(undefined)
+          }}
+        >
+          <div class="-m-3 p-2 flex flex-col w-72">
+            <div class="px-4 pt-2 pb-1 flex items-center gap-2">
+              <div class="text-14-medium text-text-strong truncate grow">{displayName(props.project)}</div>
+              <Tooltip value={language.t("common.close")} placement="top" gutter={6}>
+                <IconButton
+                  icon="circle-x"
+                  variant="ghost"
+                  class="shrink-0"
+                  data-action="project-close-hover"
+                  data-project={base64Encode(props.project.worktree)}
+                  aria-label={language.t("common.close")}
+                  onClick={(event) => {
+                    event.stopPropagation()
+                    setOpen(false)
+                    props.ctx.closeProject(props.project.worktree)
+                  }}
+                />
+              </Tooltip>
+            </div>
+            <div class="px-4 pb-2 text-12-medium text-text-weak">{language.t("sidebar.project.recentSessions")}</div>
+            <div class="px-2 pb-2 flex flex-col gap-2">
+              <Show
+                when={workspaceEnabled()}
+                fallback={
+                  <For each={projectSessions()}>
+                    {(session) => (
+                      <SessionItem
+                        {...props.ctx.sessionProps}
+                        session={session}
+                        slug={base64Encode(props.project.worktree)}
+                        dense
+                        mobile={props.mobile}
+                        popover={false}
+                        children={projectChildren()}
+                      />
+                    )}
+                  </For>
+                }
+              >
+                <For each={workspaces()}>
+                  {(directory) => {
+                    const sessions = createMemo(() => workspaceSessions(directory))
+                    const children = createMemo(() => workspaceChildren(directory))
+                    return (
+                      <div class="flex flex-col gap-1">
+                        <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+                          <div class="shrink-0 size-6 flex items-center justify-center">
+                            <Icon name="branch" size="small" class="text-icon-base" />
+                          </div>
+                          <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
+                        </div>
+                        <For each={sessions()}>
+                          {(session) => (
+                            <SessionItem
+                              {...props.ctx.sessionProps}
+                              session={session}
+                              slug={base64Encode(directory)}
+                              dense
+                              mobile={props.mobile}
+                              popover={false}
+                              children={children()}
+                            />
+                          )}
+                        </For>
+                      </div>
+                    )
+                  }}
+                </For>
+              </Show>
+            </div>
+            <div class="px-2 py-2 border-t border-border-weak-base">
+              <Button
+                variant="ghost"
+                class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
+                onClick={() => {
+                  props.ctx.openSidebar()
+                  setOpen(false)
+                  if (selected()) return
+                  props.ctx.navigateToProject(props.project.worktree)
+                }}
+              >
+                {language.t("sidebar.project.viewAllSessions")}
+              </Button>
+            </div>
+          </div>
+        </HoverCard>
+      </Show>
+    </div>
+  )
+}

+ 1 - 0
packages/app/src/pages/layout/sidebar-shell-helpers.ts

@@ -0,0 +1 @@
+export const sidebarExpanded = (mobile: boolean | undefined, opened: boolean) => !!mobile || opened

+ 13 - 0
packages/app/src/pages/layout/sidebar-shell.test.ts

@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { sidebarExpanded } from "./sidebar-shell-helpers"
+
+describe("sidebarExpanded", () => {
+  test("expands on mobile regardless of desktop open state", () => {
+    expect(sidebarExpanded(true, false)).toBe(true)
+  })
+
+  test("follows desktop open state when not mobile", () => {
+    expect(sidebarExpanded(false, true)).toBe(true)
+    expect(sidebarExpanded(false, false)).toBe(false)
+  })
+})

+ 109 - 0
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -0,0 +1,109 @@
+import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import {
+  DragDropProvider,
+  DragDropSensors,
+  DragOverlay,
+  SortableProvider,
+  closestCenter,
+  type DragEvent,
+} from "@thisbeyond/solid-dnd"
+import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { type LocalProject } from "@/context/layout"
+import { sidebarExpanded } from "./sidebar-shell-helpers"
+
+export const SidebarContent = (props: {
+  mobile?: boolean
+  opened: Accessor<boolean>
+  aimMove: (event: MouseEvent) => void
+  projects: Accessor<LocalProject[]>
+  renderProject: (project: LocalProject) => JSX.Element
+  handleDragStart: (event: unknown) => void
+  handleDragEnd: () => void
+  handleDragOver: (event: DragEvent) => void
+  openProjectLabel: JSX.Element
+  openProjectKeybind: Accessor<string | undefined>
+  onOpenProject: () => void
+  renderProjectOverlay: () => JSX.Element
+  settingsLabel: Accessor<string>
+  settingsKeybind: Accessor<string | undefined>
+  onOpenSettings: () => void
+  helpLabel: Accessor<string>
+  onOpenHelp: () => void
+  renderPanel: () => JSX.Element
+}): JSX.Element => {
+  const expanded = createMemo(() => sidebarExpanded(props.mobile, props.opened()))
+
+  return (
+    <div class="flex h-full w-full overflow-hidden">
+      <div
+        class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden"
+        onMouseMove={props.aimMove}
+      >
+        <div class="flex-1 min-h-0 w-full">
+          <DragDropProvider
+            onDragStart={props.handleDragStart}
+            onDragEnd={props.handleDragEnd}
+            onDragOver={props.handleDragOver}
+            collisionDetector={closestCenter}
+          >
+            <DragDropSensors />
+            <ConstrainDragXAxis />
+            <div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
+              <SortableProvider ids={props.projects().map((p) => p.worktree)}>
+                <For each={props.projects()}>{(project) => props.renderProject(project)}</For>
+              </SortableProvider>
+              <Tooltip
+                placement={props.mobile ? "bottom" : "right"}
+                value={
+                  <div class="flex items-center gap-2">
+                    <span>{props.openProjectLabel}</span>
+                    <Show when={!props.mobile && !!props.openProjectKeybind()}>
+                      <span class="text-icon-base text-12-medium">{props.openProjectKeybind()}</span>
+                    </Show>
+                  </div>
+                }
+              >
+                <IconButton
+                  icon="plus"
+                  variant="ghost"
+                  size="large"
+                  onClick={props.onOpenProject}
+                  aria-label={typeof props.openProjectLabel === "string" ? props.openProjectLabel : undefined}
+                />
+              </Tooltip>
+            </div>
+            <DragOverlay>{props.renderProjectOverlay()}</DragOverlay>
+          </DragDropProvider>
+        </div>
+        <div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
+          <TooltipKeybind
+            placement={props.mobile ? "bottom" : "right"}
+            title={props.settingsLabel()}
+            keybind={props.settingsKeybind() ?? ""}
+          >
+            <IconButton
+              icon="settings-gear"
+              variant="ghost"
+              size="large"
+              onClick={props.onOpenSettings}
+              aria-label={props.settingsLabel()}
+            />
+          </TooltipKeybind>
+          <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.helpLabel()}>
+            <IconButton
+              icon="help"
+              variant="ghost"
+              size="large"
+              onClick={props.onOpenHelp}
+              aria-label={props.helpLabel()}
+            />
+          </Tooltip>
+        </div>
+      </div>
+
+      <Show when={expanded()}>{props.renderPanel()}</Show>
+    </div>
+  )
+}

+ 2 - 0
packages/app/src/pages/layout/sidebar-workspace-helpers.ts

@@ -0,0 +1,2 @@
+export const workspaceOpenState = (expanded: Record<string, boolean>, directory: string, local: boolean) =>
+  expanded[directory] ?? local

+ 13 - 0
packages/app/src/pages/layout/sidebar-workspace.test.ts

@@ -0,0 +1,13 @@
+import { describe, expect, test } from "bun:test"
+import { workspaceOpenState } from "./sidebar-workspace-helpers"
+
+describe("workspaceOpenState", () => {
+  test("defaults to local workspace open", () => {
+    expect(workspaceOpenState({}, "/tmp/root", true)).toBe(true)
+  })
+
+  test("uses persisted expansion state when present", () => {
+    expect(workspaceOpenState({ "/tmp/root": false }, "/tmp/root", true)).toBe(false)
+    expect(workspaceOpenState({ "/tmp/branch": true }, "/tmp/branch", false)).toBe(true)
+  })
+})

+ 387 - 0
packages/app/src/pages/layout/sidebar-workspace.tsx

@@ -0,0 +1,387 @@
+import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSortable } from "@thisbeyond/solid-dnd"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { getFilename } from "@opencode-ai/util/path"
+import { Button } from "@opencode-ai/ui/button"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Spinner } from "@opencode-ai/ui/spinner"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { type Session } from "@opencode-ai/sdk/v2/client"
+import { type LocalProject } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
+import { childMapByParent, sortedRootSessions } from "./helpers"
+
+type InlineEditorComponent = (props: {
+  id: string
+  value: Accessor<string>
+  onSave: (next: string) => void
+  class?: string
+  displayClass?: string
+  editing?: boolean
+  stopPropagation?: boolean
+  openOnDblClick?: boolean
+}) => JSX.Element
+
+export type WorkspaceSidebarContext = {
+  currentDir: Accessor<string>
+  sidebarExpanded: Accessor<boolean>
+  sidebarHovering: Accessor<boolean>
+  nav: Accessor<HTMLElement | undefined>
+  hoverSession: Accessor<string | undefined>
+  setHoverSession: (id: string | undefined) => void
+  clearHoverProjectSoon: () => void
+  prefetchSession: (session: Session, priority?: "high" | "low") => void
+  archiveSession: (session: Session) => Promise<void>
+  workspaceName: (directory: string, projectId?: string, branch?: string) => string | undefined
+  renameWorkspace: (directory: string, next: string, projectId?: string, branch?: string) => void
+  editorOpen: (id: string) => boolean
+  openEditor: (id: string, value: string) => void
+  closeEditor: () => void
+  setEditor: (key: "value", value: string) => void
+  InlineEditor: InlineEditorComponent
+  isBusy: (directory: string) => boolean
+  workspaceExpanded: (directory: string, local: boolean) => boolean
+  setWorkspaceExpanded: (directory: string, value: boolean) => void
+  showResetWorkspaceDialog: (root: string, directory: string) => void
+  showDeleteWorkspaceDialog: (root: string, directory: string) => void
+  setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void
+}
+
+export const WorkspaceDragOverlay = (props: {
+  sidebarProject: Accessor<LocalProject | undefined>
+  activeWorkspace: Accessor<string | undefined>
+  workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
+}): JSX.Element => {
+  const globalSync = useGlobalSync()
+  const language = useLanguage()
+  const label = createMemo(() => {
+    const project = props.sidebarProject()
+    if (!project) return
+    const directory = props.activeWorkspace()
+    if (!directory) return
+
+    const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
+    const kind =
+      directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+    const name = props.workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
+    return `${kind} : ${name}`
+  })
+
+  return (
+    <Show when={label()}>
+      {(value) => <div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>}
+    </Show>
+  )
+}
+
+export const SortableWorkspace = (props: {
+  ctx: WorkspaceSidebarContext
+  directory: string
+  project: LocalProject
+  mobile?: boolean
+}): JSX.Element => {
+  const globalSync = useGlobalSync()
+  const language = useLanguage()
+  const sortable = createSortable(props.directory)
+  const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
+  const [menu, setMenu] = createStore({
+    open: false,
+    pendingRename: false,
+  })
+  const slug = createMemo(() => base64Encode(props.directory))
+  const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now()))
+  const children = createMemo(() => childMapByParent(workspaceStore.session))
+  const local = createMemo(() => props.directory === props.project.worktree)
+  const active = createMemo(() => props.ctx.currentDir() === props.directory)
+  const workspaceValue = createMemo(() => {
+    const branch = workspaceStore.vcs?.branch
+    const name = branch ?? getFilename(props.directory)
+    return props.ctx.workspaceName(props.directory, props.project.id, branch) ?? name
+  })
+  const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
+  const boot = createMemo(() => open() || active())
+  const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
+  const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
+  const busy = createMemo(() => props.ctx.isBusy(props.directory))
+  const wasBusy = createMemo((prev) => prev || busy(), false)
+  const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
+  const loadMore = async () => {
+    setWorkspaceStore("limit", (limit) => limit + 5)
+    await globalSync.project.loadSessions(props.directory)
+  }
+
+  const workspaceEditActive = createMemo(() => props.ctx.editorOpen(`workspace:${props.directory}`))
+
+  const openWrapper = (value: boolean) => {
+    props.ctx.setWorkspaceExpanded(props.directory, value)
+    if (value) return
+    if (props.ctx.editorOpen(`workspace:${props.directory}`)) props.ctx.closeEditor()
+  }
+
+  createEffect(() => {
+    if (!boot()) return
+    globalSync.child(props.directory, { bootstrap: true })
+  })
+
+  const header = () => (
+    <div class="flex items-center gap-1 min-w-0 flex-1">
+      <div class="flex items-center justify-center shrink-0 size-6">
+        <Show when={busy()} fallback={<Icon name="branch" size="small" />}>
+          <Spinner class="size-[15px]" />
+        </Show>
+      </div>
+      <span class="text-14-medium text-text-base shrink-0">
+        {local() ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")} :
+      </span>
+      <Show
+        when={!local()}
+        fallback={
+          <span class="text-14-medium text-text-base min-w-0 truncate">
+            {workspaceStore.vcs?.branch ?? getFilename(props.directory)}
+          </span>
+        }
+      >
+        <props.ctx.InlineEditor
+          id={`workspace:${props.directory}`}
+          value={workspaceValue}
+          onSave={(next) => {
+            const trimmed = next.trim()
+            if (!trimmed) return
+            props.ctx.renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
+            props.ctx.setEditor("value", workspaceValue())
+          }}
+          class="text-14-medium text-text-base min-w-0 truncate"
+          displayClass="text-14-medium text-text-base min-w-0 truncate"
+          editing={workspaceEditActive()}
+          stopPropagation={false}
+          openOnDblClick={false}
+        />
+      </Show>
+      <Icon
+        name={open() ? "chevron-down" : "chevron-right"}
+        size="small"
+        class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
+      />
+    </div>
+  )
+
+  return (
+    <div
+      // @ts-ignore
+      use:sortable
+      classList={{
+        "opacity-30": sortable.isActiveDraggable,
+        "opacity-50 pointer-events-none": busy(),
+      }}
+    >
+      <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
+        <div class="px-2 py-1">
+          <div
+            class="group/workspace relative"
+            data-component="workspace-item"
+            data-workspace={base64Encode(props.directory)}
+          >
+            <div class="flex items-center gap-1">
+              <Show
+                when={workspaceEditActive()}
+                fallback={
+                  <Collapsible.Trigger
+                    class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
+                    data-action="workspace-toggle"
+                    data-workspace={base64Encode(props.directory)}
+                  >
+                    {header()}
+                  </Collapsible.Trigger>
+                }
+              >
+                <div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
+              </Show>
+              <div
+                class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
+                classList={{
+                  "opacity-100 pointer-events-auto": menu.open,
+                  "opacity-0 pointer-events-none": !menu.open,
+                  "group-hover/workspace:opacity-100 group-hover/workspace:pointer-events-auto": true,
+                  "group-focus-within/workspace:opacity-100 group-focus-within/workspace:pointer-events-auto": true,
+                }}
+              >
+                <DropdownMenu
+                  modal={!props.ctx.sidebarHovering()}
+                  open={menu.open}
+                  onOpenChange={(open) => setMenu("open", open)}
+                >
+                  <Tooltip value={language.t("common.moreOptions")} placement="top">
+                    <DropdownMenu.Trigger
+                      as={IconButton}
+                      icon="dot-grid"
+                      variant="ghost"
+                      class="size-6 rounded-md"
+                      data-action="workspace-menu"
+                      data-workspace={base64Encode(props.directory)}
+                      aria-label={language.t("common.moreOptions")}
+                    />
+                  </Tooltip>
+                  <DropdownMenu.Portal mount={!props.mobile ? props.ctx.nav() : undefined}>
+                    <DropdownMenu.Content
+                      onCloseAutoFocus={(event) => {
+                        if (!menu.pendingRename) return
+                        event.preventDefault()
+                        setMenu("pendingRename", false)
+                        props.ctx.openEditor(`workspace:${props.directory}`, workspaceValue())
+                      }}
+                    >
+                      <DropdownMenu.Item
+                        disabled={local()}
+                        onSelect={() => {
+                          setMenu("pendingRename", true)
+                          setMenu("open", false)
+                        }}
+                      >
+                        <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+                      </DropdownMenu.Item>
+                      <DropdownMenu.Item
+                        disabled={local() || busy()}
+                        onSelect={() => props.ctx.showResetWorkspaceDialog(props.project.worktree, props.directory)}
+                      >
+                        <DropdownMenu.ItemLabel>{language.t("common.reset")}</DropdownMenu.ItemLabel>
+                      </DropdownMenu.Item>
+                      <DropdownMenu.Item
+                        disabled={local() || busy()}
+                        onSelect={() => props.ctx.showDeleteWorkspaceDialog(props.project.worktree, props.directory)}
+                      >
+                        <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
+                      </DropdownMenu.Item>
+                    </DropdownMenu.Content>
+                  </DropdownMenu.Portal>
+                </DropdownMenu>
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <Collapsible.Content>
+          <nav class="flex flex-col gap-1 px-2">
+            <NewSessionItem
+              slug={slug()}
+              mobile={props.mobile}
+              sidebarExpanded={props.ctx.sidebarExpanded}
+              clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+              setHoverSession={props.ctx.setHoverSession}
+            />
+            <Show when={loading()}>
+              <SessionSkeleton />
+            </Show>
+            <For each={sessions()}>
+              {(session) => (
+                <SessionItem
+                  session={session}
+                  slug={slug()}
+                  mobile={props.mobile}
+                  children={children()}
+                  sidebarExpanded={props.ctx.sidebarExpanded}
+                  sidebarHovering={props.ctx.sidebarHovering}
+                  nav={props.ctx.nav}
+                  hoverSession={props.ctx.hoverSession}
+                  setHoverSession={props.ctx.setHoverSession}
+                  clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+                  prefetchSession={props.ctx.prefetchSession}
+                  archiveSession={props.ctx.archiveSession}
+                />
+              )}
+            </For>
+            <Show when={hasMore()}>
+              <div class="relative w-full py-1">
+                <Button
+                  variant="ghost"
+                  class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+                  size="large"
+                  onClick={(e: MouseEvent) => {
+                    loadMore()
+                    ;(e.currentTarget as HTMLButtonElement).blur()
+                  }}
+                >
+                  {language.t("common.loadMore")}
+                </Button>
+              </div>
+            </Show>
+          </nav>
+        </Collapsible.Content>
+      </Collapsible>
+    </div>
+  )
+}
+
+export const LocalWorkspace = (props: {
+  ctx: WorkspaceSidebarContext
+  project: LocalProject
+  mobile?: boolean
+}): JSX.Element => {
+  const globalSync = useGlobalSync()
+  const language = useLanguage()
+  const workspace = createMemo(() => {
+    const [store, setStore] = globalSync.child(props.project.worktree)
+    return { store, setStore }
+  })
+  const slug = createMemo(() => base64Encode(props.project.worktree))
+  const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now()))
+  const children = createMemo(() => childMapByParent(workspace().store.session))
+  const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
+  const loading = createMemo(() => !booted() && sessions().length === 0)
+  const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
+  const loadMore = async () => {
+    workspace().setStore("limit", (limit) => limit + 5)
+    await globalSync.project.loadSessions(props.project.worktree)
+  }
+
+  return (
+    <div
+      ref={(el) => props.ctx.setScrollContainerRef(el, props.mobile)}
+      class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+    >
+      <nav class="flex flex-col gap-1 px-2">
+        <Show when={loading()}>
+          <SessionSkeleton />
+        </Show>
+        <For each={sessions()}>
+          {(session) => (
+            <SessionItem
+              session={session}
+              slug={slug()}
+              mobile={props.mobile}
+              children={children()}
+              sidebarExpanded={props.ctx.sidebarExpanded}
+              sidebarHovering={props.ctx.sidebarHovering}
+              nav={props.ctx.nav}
+              hoverSession={props.ctx.hoverSession}
+              setHoverSession={props.ctx.setHoverSession}
+              clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
+              prefetchSession={props.ctx.prefetchSession}
+              archiveSession={props.ctx.archiveSession}
+            />
+          )}
+        </For>
+        <Show when={hasMore()}>
+          <div class="relative w-full py-1">
+            <Button
+              variant="ghost"
+              class="flex w-full text-left justify-start text-14-regular text-text-weak pl-9 pr-10"
+              size="large"
+              onClick={(e: MouseEvent) => {
+                loadMore()
+                ;(e.currentTarget as HTMLButtonElement).blur()
+              }}
+            >
+              {language.t("common.loadMore")}
+            </Button>
+          </div>
+        </Show>
+      </nav>
+    </div>
+  )
+}

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 157 - 1126
packages/app/src/pages/session.tsx


+ 40 - 0
packages/app/src/pages/session/file-tab-scroll.test.ts

@@ -0,0 +1,40 @@
+import { describe, expect, test } from "bun:test"
+import { nextTabListScrollLeft } from "./file-tab-scroll"
+
+describe("nextTabListScrollLeft", () => {
+  test("does not scroll when width shrinks", () => {
+    const left = nextTabListScrollLeft({
+      prevScrollWidth: 500,
+      scrollWidth: 420,
+      clientWidth: 300,
+      prevContextOpen: false,
+      contextOpen: false,
+    })
+
+    expect(left).toBeUndefined()
+  })
+
+  test("scrolls to start when context tab opens", () => {
+    const left = nextTabListScrollLeft({
+      prevScrollWidth: 400,
+      scrollWidth: 500,
+      clientWidth: 320,
+      prevContextOpen: false,
+      contextOpen: true,
+    })
+
+    expect(left).toBe(0)
+  })
+
+  test("scrolls to right edge for new file tabs", () => {
+    const left = nextTabListScrollLeft({
+      prevScrollWidth: 500,
+      scrollWidth: 780,
+      clientWidth: 300,
+      prevContextOpen: true,
+      contextOpen: true,
+    })
+
+    expect(left).toBe(480)
+  })
+})

+ 67 - 0
packages/app/src/pages/session/file-tab-scroll.ts

@@ -0,0 +1,67 @@
+type Input = {
+  prevScrollWidth: number
+  scrollWidth: number
+  clientWidth: number
+  prevContextOpen: boolean
+  contextOpen: boolean
+}
+
+export const nextTabListScrollLeft = (input: Input) => {
+  if (input.scrollWidth <= input.prevScrollWidth) return
+  if (!input.prevContextOpen && input.contextOpen) return 0
+  if (input.scrollWidth <= input.clientWidth) return
+  return input.scrollWidth - input.clientWidth
+}
+
+export const createFileTabListSync = (input: { el: HTMLDivElement; contextOpen: () => boolean }) => {
+  let frame: number | undefined
+  let prevScrollWidth = input.el.scrollWidth
+  let prevContextOpen = input.contextOpen()
+
+  const update = () => {
+    const scrollWidth = input.el.scrollWidth
+    const clientWidth = input.el.clientWidth
+    const contextOpen = input.contextOpen()
+    const left = nextTabListScrollLeft({
+      prevScrollWidth,
+      scrollWidth,
+      clientWidth,
+      prevContextOpen,
+      contextOpen,
+    })
+
+    if (left !== undefined) {
+      input.el.scrollTo({
+        left,
+        behavior: "smooth",
+      })
+    }
+
+    prevScrollWidth = scrollWidth
+    prevContextOpen = contextOpen
+  }
+
+  const schedule = () => {
+    if (frame !== undefined) cancelAnimationFrame(frame)
+    frame = requestAnimationFrame(() => {
+      frame = undefined
+      update()
+    })
+  }
+
+  const onWheel = (e: WheelEvent) => {
+    if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
+    input.el.scrollLeft += e.deltaY > 0 ? 50 : -50
+    e.preventDefault()
+  }
+
+  input.el.addEventListener("wheel", onWheel, { passive: false })
+  const observer = new MutationObserver(schedule)
+  observer.observe(input.el, { childList: true })
+
+  return () => {
+    input.el.removeEventListener("wheel", onWheel)
+    observer.disconnect()
+    if (frame !== undefined) cancelAnimationFrame(frame)
+  }
+}

+ 516 - 0
packages/app/src/pages/session/file-tabs.tsx

@@ -0,0 +1,516 @@
+import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Dynamic } from "solid-js/web"
+import { checksum } from "@opencode-ai/util/encode"
+import { decode64 } from "@/utils/base64"
+import { showToast } from "@opencode-ai/ui/toast"
+import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
+import { Mark } from "@opencode-ai/ui/logo"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { useLayout } from "@/context/layout"
+import { useFile, type SelectedLineRange } from "@/context/file"
+import { useComments } from "@/context/comments"
+import { useLanguage } from "@/context/language"
+
+export function FileTabContent(props: {
+  tab: string
+  activeTab: () => string
+  tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+  handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
+  file: ReturnType<typeof useFile>
+  comments: ReturnType<typeof useComments>
+  language: ReturnType<typeof useLanguage>
+  codeComponent: NonNullable<ValidComponent>
+  addCommentToContext: (input: {
+    file: string
+    selection: SelectedLineRange
+    comment: string
+    preview?: string
+    origin?: "review" | "file"
+  }) => void
+}) {
+  let scroll: HTMLDivElement | undefined
+  let scrollFrame: number | undefined
+  let pending: { x: number; y: number } | undefined
+  let codeScroll: HTMLElement[] = []
+
+  const path = createMemo(() => props.file.pathFromTab(props.tab))
+  const state = createMemo(() => {
+    const p = path()
+    if (!p) return
+    return props.file.get(p)
+  })
+  const contents = createMemo(() => state()?.content?.content ?? "")
+  const cacheKey = createMemo(() => checksum(contents()))
+  const isImage = createMemo(() => {
+    const c = state()?.content
+    return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
+  })
+  const isSvg = createMemo(() => {
+    const c = state()?.content
+    return c?.mimeType === "image/svg+xml"
+  })
+  const isBinary = createMemo(() => state()?.content?.type === "binary")
+  const svgContent = createMemo(() => {
+    if (!isSvg()) return
+    const c = state()?.content
+    if (!c) return
+    if (c.encoding !== "base64") return c.content
+    return decode64(c.content)
+  })
+
+  const svgDecodeFailed = createMemo(() => {
+    if (!isSvg()) return false
+    const c = state()?.content
+    if (!c) return false
+    if (c.encoding !== "base64") return false
+    return svgContent() === undefined
+  })
+
+  const svgToast = { shown: false }
+  createEffect(() => {
+    if (!svgDecodeFailed()) return
+    if (svgToast.shown) return
+    svgToast.shown = true
+    showToast({
+      variant: "error",
+      title: props.language.t("toast.file.loadFailed.title"),
+      description: "Invalid base64 content.",
+    })
+  })
+  const svgPreviewUrl = createMemo(() => {
+    if (!isSvg()) return
+    const c = state()?.content
+    if (!c) return
+    if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
+  })
+  const imageDataUrl = createMemo(() => {
+    if (!isImage()) return
+    const c = state()?.content
+    return `data:${c?.mimeType};base64,${c?.content}`
+  })
+  const selectedLines = createMemo(() => {
+    const p = path()
+    if (!p) return null
+    if (props.file.ready()) return props.file.selectedLines(p) ?? null
+    return props.handoffFiles()?.[p] ?? null
+  })
+
+  let wrap: HTMLDivElement | undefined
+
+  const fileComments = createMemo(() => {
+    const p = path()
+    if (!p) return []
+    return props.comments.list(p)
+  })
+
+  const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
+
+  const [note, setNote] = createStore({
+    openedComment: null as string | null,
+    commenting: null as SelectedLineRange | null,
+    draft: "",
+    positions: {} as Record<string, number>,
+    draftTop: undefined as number | undefined,
+  })
+
+  const openedComment = () => note.openedComment
+  const setOpenedComment = (
+    value: typeof note.openedComment | ((value: typeof note.openedComment) => typeof note.openedComment),
+  ) => setNote("openedComment", value)
+
+  const commenting = () => note.commenting
+  const setCommenting = (value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting)) =>
+    setNote("commenting", value)
+
+  const draft = () => note.draft
+  const setDraft = (value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft)) =>
+    setNote("draft", value)
+
+  const positions = () => note.positions
+  const setPositions = (value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions)) =>
+    setNote("positions", value)
+
+  const draftTop = () => note.draftTop
+  const setDraftTop = (value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop)) =>
+    setNote("draftTop", value)
+
+  const commentLabel = (range: SelectedLineRange) => {
+    const start = Math.min(range.start, range.end)
+    const end = Math.max(range.start, range.end)
+    if (start === end) return `line ${start}`
+    return `lines ${start}-${end}`
+  }
+
+  const getRoot = () => {
+    const el = wrap
+    if (!el) return
+
+    const host = el.querySelector("diffs-container")
+    if (!(host instanceof HTMLElement)) return
+
+    const root = host.shadowRoot
+    if (!root) return
+
+    return root
+  }
+
+  const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
+    const line = Math.max(range.start, range.end)
+    const node = root.querySelector(`[data-line="${line}"]`)
+    if (!(node instanceof HTMLElement)) return
+    return node
+  }
+
+  const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
+    const wrapperRect = wrapper.getBoundingClientRect()
+    const rect = marker.getBoundingClientRect()
+    return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
+  }
+
+  const updateComments = () => {
+    const el = wrap
+    const root = getRoot()
+    if (!el || !root) {
+      setPositions({})
+      setDraftTop(undefined)
+      return
+    }
+
+    const next: Record<string, number> = {}
+    for (const comment of fileComments()) {
+      const marker = findMarker(root, comment.selection)
+      if (!marker) continue
+      next[comment.id] = markerTop(el, marker)
+    }
+
+    setPositions(next)
+
+    const range = commenting()
+    if (!range) {
+      setDraftTop(undefined)
+      return
+    }
+
+    const marker = findMarker(root, range)
+    if (!marker) {
+      setDraftTop(undefined)
+      return
+    }
+
+    setDraftTop(markerTop(el, marker))
+  }
+
+  const scheduleComments = () => {
+    requestAnimationFrame(updateComments)
+  }
+
+  createEffect(() => {
+    fileComments()
+    scheduleComments()
+  })
+
+  createEffect(() => {
+    const range = commenting()
+    scheduleComments()
+    if (!range) return
+    setDraft("")
+  })
+
+  createEffect(() => {
+    const focus = props.comments.focus()
+    const p = path()
+    if (!focus || !p) return
+    if (focus.file !== p) return
+    if (props.activeTab() !== props.tab) return
+
+    const target = fileComments().find((comment) => comment.id === focus.id)
+    if (!target) return
+
+    setOpenedComment(target.id)
+    setCommenting(null)
+    props.file.setSelectedLines(p, target.selection)
+    requestAnimationFrame(() => props.comments.clearFocus())
+  })
+
+  const getCodeScroll = () => {
+    const el = scroll
+    if (!el) return []
+
+    const host = el.querySelector("diffs-container")
+    if (!(host instanceof HTMLElement)) return []
+
+    const root = host.shadowRoot
+    if (!root) return []
+
+    return Array.from(root.querySelectorAll("[data-code]")).filter(
+      (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
+    )
+  }
+
+  const queueScrollUpdate = (next: { x: number; y: number }) => {
+    pending = next
+    if (scrollFrame !== undefined) return
+
+    scrollFrame = requestAnimationFrame(() => {
+      scrollFrame = undefined
+
+      const out = pending
+      pending = undefined
+      if (!out) return
+
+      props.view().setScroll(props.tab, out)
+    })
+  }
+
+  const handleCodeScroll = (event: Event) => {
+    const el = scroll
+    if (!el) return
+
+    const target = event.currentTarget
+    if (!(target instanceof HTMLElement)) return
+
+    queueScrollUpdate({
+      x: target.scrollLeft,
+      y: el.scrollTop,
+    })
+  }
+
+  const syncCodeScroll = () => {
+    const next = getCodeScroll()
+    if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
+
+    for (const item of codeScroll) {
+      item.removeEventListener("scroll", handleCodeScroll)
+    }
+
+    codeScroll = next
+
+    for (const item of codeScroll) {
+      item.addEventListener("scroll", handleCodeScroll)
+    }
+  }
+
+  const restoreScroll = () => {
+    const el = scroll
+    if (!el) return
+
+    const s = props.view()?.scroll(props.tab)
+    if (!s) return
+
+    syncCodeScroll()
+
+    if (codeScroll.length > 0) {
+      for (const item of codeScroll) {
+        if (item.scrollLeft !== s.x) item.scrollLeft = s.x
+      }
+    }
+
+    if (el.scrollTop !== s.y) el.scrollTop = s.y
+    if (codeScroll.length > 0) return
+    if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+  }
+
+  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+    if (codeScroll.length === 0) syncCodeScroll()
+
+    queueScrollUpdate({
+      x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+      y: event.currentTarget.scrollTop,
+    })
+  }
+
+  createEffect(
+    on(
+      () => state()?.loaded,
+      (loaded) => {
+        if (!loaded) return
+        requestAnimationFrame(restoreScroll)
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => props.file.ready(),
+      (ready) => {
+        if (!ready) return
+        requestAnimationFrame(restoreScroll)
+      },
+      { defer: true },
+    ),
+  )
+
+  createEffect(
+    on(
+      () => props.tabs().active() === props.tab,
+      (active) => {
+        if (!active) return
+        if (!state()?.loaded) return
+        requestAnimationFrame(restoreScroll)
+      },
+    ),
+  )
+
+  onCleanup(() => {
+    for (const item of codeScroll) {
+      item.removeEventListener("scroll", handleCodeScroll)
+    }
+
+    if (scrollFrame === undefined) return
+    cancelAnimationFrame(scrollFrame)
+  })
+
+  const renderCode = (source: string, wrapperClass: string) => (
+    <div
+      ref={(el) => {
+        wrap = el
+        scheduleComments()
+      }}
+      class={`relative overflow-hidden ${wrapperClass}`}
+    >
+      <Dynamic
+        component={props.codeComponent}
+        file={{
+          name: path() ?? "",
+          contents: source,
+          cacheKey: cacheKey(),
+        }}
+        enableLineSelection
+        selectedLines={selectedLines()}
+        commentedLines={commentedLines()}
+        onRendered={() => {
+          requestAnimationFrame(restoreScroll)
+          requestAnimationFrame(scheduleComments)
+        }}
+        onLineSelected={(range: SelectedLineRange | null) => {
+          const p = path()
+          if (!p) return
+          props.file.setSelectedLines(p, range)
+          if (!range) setCommenting(null)
+        }}
+        onLineSelectionEnd={(range: SelectedLineRange | null) => {
+          if (!range) {
+            setCommenting(null)
+            return
+          }
+
+          setOpenedComment(null)
+          setCommenting(range)
+        }}
+        overflow="scroll"
+        class="select-text"
+      />
+      <For each={fileComments()}>
+        {(comment) => (
+          <LineCommentView
+            id={comment.id}
+            top={positions()[comment.id]}
+            open={openedComment() === comment.id}
+            comment={comment.comment}
+            selection={commentLabel(comment.selection)}
+            onMouseEnter={() => {
+              const p = path()
+              if (!p) return
+              props.file.setSelectedLines(p, comment.selection)
+            }}
+            onClick={() => {
+              const p = path()
+              if (!p) return
+              setCommenting(null)
+              setOpenedComment((current) => (current === comment.id ? null : comment.id))
+              props.file.setSelectedLines(p, comment.selection)
+            }}
+          />
+        )}
+      </For>
+      <Show when={commenting()}>
+        {(range) => (
+          <Show when={draftTop() !== undefined}>
+            <LineCommentEditor
+              top={draftTop()}
+              value={draft()}
+              selection={commentLabel(range())}
+              onInput={(value) => setDraft(value)}
+              onCancel={() => setCommenting(null)}
+              onSubmit={(value) => {
+                const p = path()
+                if (!p) return
+                props.addCommentToContext({
+                  file: p,
+                  selection: range(),
+                  comment: value,
+                  origin: "file",
+                })
+                setCommenting(null)
+              }}
+              onPopoverFocusOut={(e: FocusEvent) => {
+                const current = e.currentTarget as HTMLDivElement
+                const target = e.relatedTarget
+                if (target instanceof Node && current.contains(target)) return
+
+                setTimeout(() => {
+                  if (!document.activeElement || !current.contains(document.activeElement)) {
+                    setCommenting(null)
+                  }
+                }, 0)
+              }}
+            />
+          </Show>
+        )}
+      </Show>
+    </div>
+  )
+
+  return (
+    <Tabs.Content
+      value={props.tab}
+      class="mt-3 relative"
+      ref={(el: HTMLDivElement) => {
+        scroll = el
+        restoreScroll()
+      }}
+      onScroll={handleScroll}
+    >
+      <Switch>
+        <Match when={state()?.loaded && isImage()}>
+          <div class="px-6 py-4 pb-40">
+            <img
+              src={imageDataUrl()}
+              alt={path()}
+              class="max-w-full"
+              onLoad={() => requestAnimationFrame(restoreScroll)}
+            />
+          </div>
+        </Match>
+        <Match when={state()?.loaded && isSvg()}>
+          <div class="flex flex-col gap-4 px-6 py-4">
+            {renderCode(svgContent() ?? "", "")}
+            <Show when={svgPreviewUrl()}>
+              <div class="flex justify-center pb-40">
+                <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+              </div>
+            </Show>
+          </div>
+        </Match>
+        <Match when={state()?.loaded && isBinary()}>
+          <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+            <Mark class="w-14 opacity-10" />
+            <div class="flex flex-col gap-2 max-w-md">
+              <div class="text-14-semibold text-text-strong truncate">{path()?.split("/").pop()}</div>
+              <div class="text-14-regular text-text-weak">{props.language.t("session.files.binaryContent")}</div>
+            </div>
+          </div>
+        </Match>
+        <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+        <Match when={state()?.loading}>
+          <div class="px-6 py-4 text-text-weak">{props.language.t("common.loading")}...</div>
+        </Match>
+        <Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
+      </Switch>
+    </Tabs.Content>
+  )
+}

+ 62 - 0
packages/app/src/pages/session/message-gesture.test.ts

@@ -0,0 +1,62 @@
+import { describe, expect, test } from "bun:test"
+import { normalizeWheelDelta, shouldMarkBoundaryGesture } from "./message-gesture"
+
+describe("normalizeWheelDelta", () => {
+  test("converts line mode to px", () => {
+    expect(normalizeWheelDelta({ deltaY: 3, deltaMode: 1, rootHeight: 500 })).toBe(120)
+  })
+
+  test("converts page mode to container height", () => {
+    expect(normalizeWheelDelta({ deltaY: -1, deltaMode: 2, rootHeight: 600 })).toBe(-600)
+  })
+
+  test("keeps pixel mode unchanged", () => {
+    expect(normalizeWheelDelta({ deltaY: 16, deltaMode: 0, rootHeight: 600 })).toBe(16)
+  })
+})
+
+describe("shouldMarkBoundaryGesture", () => {
+  test("marks when nested scroller cannot scroll", () => {
+    expect(
+      shouldMarkBoundaryGesture({
+        delta: 20,
+        scrollTop: 0,
+        scrollHeight: 300,
+        clientHeight: 300,
+      }),
+    ).toBe(true)
+  })
+
+  test("marks when scrolling beyond top boundary", () => {
+    expect(
+      shouldMarkBoundaryGesture({
+        delta: -40,
+        scrollTop: 10,
+        scrollHeight: 1000,
+        clientHeight: 400,
+      }),
+    ).toBe(true)
+  })
+
+  test("marks when scrolling beyond bottom boundary", () => {
+    expect(
+      shouldMarkBoundaryGesture({
+        delta: 50,
+        scrollTop: 580,
+        scrollHeight: 1000,
+        clientHeight: 400,
+      }),
+    ).toBe(true)
+  })
+
+  test("does not mark when nested scroller can consume movement", () => {
+    expect(
+      shouldMarkBoundaryGesture({
+        delta: 20,
+        scrollTop: 200,
+        scrollHeight: 1000,
+        clientHeight: 400,
+      }),
+    ).toBe(false)
+  })
+})

+ 21 - 0
packages/app/src/pages/session/message-gesture.ts

@@ -0,0 +1,21 @@
+export const normalizeWheelDelta = (input: { deltaY: number; deltaMode: number; rootHeight: number }) => {
+  if (input.deltaMode === 1) return input.deltaY * 40
+  if (input.deltaMode === 2) return input.deltaY * input.rootHeight
+  return input.deltaY
+}
+
+export const shouldMarkBoundaryGesture = (input: {
+  delta: number
+  scrollTop: number
+  scrollHeight: number
+  clientHeight: number
+}) => {
+  const max = input.scrollHeight - input.clientHeight
+  if (max <= 1) return true
+  if (!input.delta) return false
+
+  if (input.delta < 0) return input.scrollTop + input.delta <= 0
+
+  const remaining = max - input.scrollTop
+  return input.delta > remaining
+}

+ 348 - 0
packages/app/src/pages/session/message-timeline.tsx

@@ -0,0 +1,348 @@
+import { For, onCleanup, onMount, Show, type JSX } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import type { UserMessage } from "@opencode-ai/sdk/v2"
+import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
+
+export function MessageTimeline(props: {
+  mobileChanges: boolean
+  mobileFallback: JSX.Element
+  scroll: { overflow: boolean; bottom: boolean }
+  onResumeScroll: () => void
+  setScrollRef: (el: HTMLDivElement | undefined) => void
+  onScheduleScrollState: (el: HTMLDivElement) => void
+  onAutoScrollHandleScroll: () => void
+  onMarkScrollGesture: (target?: EventTarget | null) => void
+  hasScrollGesture: () => boolean
+  isDesktop: boolean
+  onScrollSpyScroll: () => void
+  onAutoScrollInteraction: (event: MouseEvent) => void
+  showHeader: boolean
+  centered: boolean
+  title?: string
+  parentID?: string
+  openTitleEditor: () => void
+  closeTitleEditor: () => void
+  saveTitleEditor: () => void | Promise<void>
+  titleRef: (el: HTMLInputElement) => void
+  titleState: {
+    draft: string
+    editing: boolean
+    saving: boolean
+    menuOpen: boolean
+    pendingRename: boolean
+  }
+  onTitleDraft: (value: string) => void
+  onTitleMenuOpen: (open: boolean) => void
+  onTitlePendingRename: (value: boolean) => void
+  onNavigateParent: () => void
+  sessionID: string
+  onArchiveSession: (sessionID: string) => void
+  onDeleteSession: (sessionID: string) => void
+  t: (key: string, vars?: Record<string, string | number | boolean>) => string
+  setContentRef: (el: HTMLDivElement) => void
+  turnStart: number
+  onRenderEarlier: () => void
+  historyMore: boolean
+  historyLoading: boolean
+  onLoadEarlier: () => void
+  renderedUserMessages: UserMessage[]
+  anchor: (id: string) => string
+  onRegisterMessage: (el: HTMLDivElement, id: string) => void
+  onUnregisterMessage: (id: string) => void
+  onFirstTurnMount?: () => void
+  lastUserMessageID?: string
+  expanded: Record<string, boolean>
+  onToggleExpanded: (id: string) => void
+}) {
+  let touchGesture: number | undefined
+
+  return (
+    <Show
+      when={!props.mobileChanges}
+      fallback={<div class="relative h-full overflow-hidden">{props.mobileFallback}</div>}
+    >
+      <div class="relative w-full h-full min-w-0">
+        <div
+          class="absolute left-1/2 -translate-x-1/2 bottom-[calc(var(--prompt-height,8rem)+32px)] z-[60] pointer-events-none transition-all duration-200 ease-out"
+          classList={{
+            "opacity-100 translate-y-0 scale-100": props.scroll.overflow && !props.scroll.bottom,
+            "opacity-0 translate-y-2 scale-95 pointer-events-none": !props.scroll.overflow || props.scroll.bottom,
+          }}
+        >
+          <button
+            class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
+            onClick={props.onResumeScroll}
+          >
+            <Icon name="arrow-down-to-line" />
+          </button>
+        </div>
+        <div
+          ref={props.setScrollRef}
+          onWheel={(e) => {
+            const root = e.currentTarget
+            const target = e.target instanceof Element ? e.target : undefined
+            const nested = target?.closest("[data-scrollable]")
+            if (!nested || nested === root) {
+              props.onMarkScrollGesture(root)
+              return
+            }
+
+            if (!(nested instanceof HTMLElement)) {
+              props.onMarkScrollGesture(root)
+              return
+            }
+
+            const delta = normalizeWheelDelta({
+              deltaY: e.deltaY,
+              deltaMode: e.deltaMode,
+              rootHeight: root.clientHeight,
+            })
+            if (!delta) return
+
+            if (
+              shouldMarkBoundaryGesture({
+                delta,
+                scrollTop: nested.scrollTop,
+                scrollHeight: nested.scrollHeight,
+                clientHeight: nested.clientHeight,
+              })
+            ) {
+              props.onMarkScrollGesture(root)
+            }
+          }}
+          onTouchStart={(e) => {
+            touchGesture = e.touches[0]?.clientY
+          }}
+          onTouchMove={(e) => {
+            const next = e.touches[0]?.clientY
+            const prev = touchGesture
+            touchGesture = next
+            if (next === undefined || prev === undefined) return
+
+            const delta = prev - next
+            if (!delta) return
+
+            const root = e.currentTarget
+            const target = e.target instanceof Element ? e.target : undefined
+            const nested = target?.closest("[data-scrollable]")
+            if (!nested || nested === root) {
+              props.onMarkScrollGesture(root)
+              return
+            }
+
+            if (!(nested instanceof HTMLElement)) {
+              props.onMarkScrollGesture(root)
+              return
+            }
+
+            if (
+              shouldMarkBoundaryGesture({
+                delta,
+                scrollTop: nested.scrollTop,
+                scrollHeight: nested.scrollHeight,
+                clientHeight: nested.clientHeight,
+              })
+            ) {
+              props.onMarkScrollGesture(root)
+            }
+          }}
+          onTouchEnd={() => {
+            touchGesture = undefined
+          }}
+          onTouchCancel={() => {
+            touchGesture = undefined
+          }}
+          onPointerDown={(e) => {
+            if (e.target !== e.currentTarget) return
+            props.onMarkScrollGesture(e.currentTarget)
+          }}
+          onScroll={(e) => {
+            props.onScheduleScrollState(e.currentTarget)
+            if (!props.hasScrollGesture()) return
+            props.onAutoScrollHandleScroll()
+            props.onMarkScrollGesture(e.currentTarget)
+            if (props.isDesktop) props.onScrollSpyScroll()
+          }}
+          onClick={props.onAutoScrollInteraction}
+          class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
+          style={{ "--session-title-height": props.showHeader ? "40px" : "0px" }}
+        >
+          <Show when={props.showHeader}>
+            <div
+              classList={{
+                "sticky top-0 z-30 bg-background-stronger": true,
+                "w-full": true,
+                "px-4 md:px-6": true,
+                "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+              }}
+            >
+              <div class="h-10 w-full flex items-center justify-between gap-2">
+                <div class="flex items-center gap-1 min-w-0 flex-1">
+                  <Show when={props.parentID}>
+                    <IconButton
+                      tabIndex={-1}
+                      icon="arrow-left"
+                      variant="ghost"
+                      onClick={props.onNavigateParent}
+                      aria-label={props.t("common.goBack")}
+                    />
+                  </Show>
+                  <Show when={props.title || props.titleState.editing}>
+                    <Show
+                      when={props.titleState.editing}
+                      fallback={
+                        <h1 class="text-16-medium text-text-strong truncate min-w-0" onDblClick={props.openTitleEditor}>
+                          {props.title}
+                        </h1>
+                      }
+                    >
+                      <InlineInput
+                        ref={props.titleRef}
+                        value={props.titleState.draft}
+                        disabled={props.titleState.saving}
+                        class="text-16-medium text-text-strong grow-1 min-w-0"
+                        onInput={(event) => props.onTitleDraft(event.currentTarget.value)}
+                        onKeyDown={(event) => {
+                          event.stopPropagation()
+                          if (event.key === "Enter") {
+                            event.preventDefault()
+                            void props.saveTitleEditor()
+                            return
+                          }
+                          if (event.key === "Escape") {
+                            event.preventDefault()
+                            props.closeTitleEditor()
+                          }
+                        }}
+                        onBlur={props.closeTitleEditor}
+                      />
+                    </Show>
+                  </Show>
+                </div>
+                <Show when={props.sessionID}>
+                  {(id) => (
+                    <div class="shrink-0 flex items-center">
+                      <DropdownMenu open={props.titleState.menuOpen} onOpenChange={props.onTitleMenuOpen}>
+                        <Tooltip value={props.t("common.moreOptions")} placement="top">
+                          <DropdownMenu.Trigger
+                            as={IconButton}
+                            icon="dot-grid"
+                            variant="ghost"
+                            class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+                            aria-label={props.t("common.moreOptions")}
+                          />
+                        </Tooltip>
+                        <DropdownMenu.Portal>
+                          <DropdownMenu.Content
+                            onCloseAutoFocus={(event) => {
+                              if (!props.titleState.pendingRename) return
+                              event.preventDefault()
+                              props.onTitlePendingRename(false)
+                              props.openTitleEditor()
+                            }}
+                          >
+                            <DropdownMenu.Item
+                              onSelect={() => {
+                                props.onTitlePendingRename(true)
+                                props.onTitleMenuOpen(false)
+                              }}
+                            >
+                              <DropdownMenu.ItemLabel>{props.t("common.rename")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                            <DropdownMenu.Item onSelect={() => props.onArchiveSession(id())}>
+                              <DropdownMenu.ItemLabel>{props.t("common.archive")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                            <DropdownMenu.Separator />
+                            <DropdownMenu.Item onSelect={() => props.onDeleteSession(id())}>
+                              <DropdownMenu.ItemLabel>{props.t("common.delete")}</DropdownMenu.ItemLabel>
+                            </DropdownMenu.Item>
+                          </DropdownMenu.Content>
+                        </DropdownMenu.Portal>
+                      </DropdownMenu>
+                    </div>
+                  )}
+                </Show>
+              </div>
+            </div>
+          </Show>
+
+          <div
+            ref={props.setContentRef}
+            role="log"
+            class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
+            classList={{
+              "w-full": true,
+              "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+              "mt-0.5": props.centered,
+              "mt-0": !props.centered,
+            }}
+          >
+            <Show when={props.turnStart > 0}>
+              <div class="w-full flex justify-center">
+                <Button variant="ghost" size="large" class="text-12-medium opacity-50" onClick={props.onRenderEarlier}>
+                  {props.t("session.messages.renderEarlier")}
+                </Button>
+              </div>
+            </Show>
+            <Show when={props.historyMore}>
+              <div class="w-full flex justify-center">
+                <Button
+                  variant="ghost"
+                  size="large"
+                  class="text-12-medium opacity-50"
+                  disabled={props.historyLoading}
+                  onClick={props.onLoadEarlier}
+                >
+                  {props.historyLoading
+                    ? props.t("session.messages.loadingEarlier")
+                    : props.t("session.messages.loadEarlier")}
+                </Button>
+              </div>
+            </Show>
+            <For each={props.renderedUserMessages}>
+              {(message) => {
+                if (import.meta.env.DEV && props.onFirstTurnMount) {
+                  onMount(() => props.onFirstTurnMount?.())
+                }
+
+                return (
+                  <div
+                    id={props.anchor(message.id)}
+                    data-message-id={message.id}
+                    ref={(el) => {
+                      props.onRegisterMessage(el, message.id)
+                      onCleanup(() => props.onUnregisterMessage(message.id))
+                    }}
+                    classList={{
+                      "min-w-0 w-full max-w-full": true,
+                      "md:max-w-200 3xl:max-w-[1200px]": props.centered,
+                    }}
+                  >
+                    <SessionTurn
+                      sessionID={props.sessionID}
+                      messageID={message.id}
+                      lastUserMessageID={props.lastUserMessageID}
+                      stepsExpanded={props.expanded[message.id] ?? false}
+                      onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
+                      classes={{
+                        root: "min-w-0 w-full relative",
+                        content: "flex flex-col justify-between !overflow-visible",
+                        container: "w-full px-4 md:px-6",
+                      }}
+                    />
+                  </div>
+                )
+              }}
+            </For>
+          </div>
+        </div>
+      </div>
+    </Show>
+  )
+}

+ 158 - 0
packages/app/src/pages/session/review-tab.tsx

@@ -0,0 +1,158 @@
+import { createEffect, on, onCleanup, createSignal, type JSX } from "solid-js"
+import type { FileDiff } from "@opencode-ai/sdk/v2"
+import { SessionReview } from "@opencode-ai/ui/session-review"
+import type { SelectedLineRange } from "@/context/file"
+import { useSDK } from "@/context/sdk"
+import { useLayout } from "@/context/layout"
+import type { LineComment } from "@/context/comments"
+
+export type DiffStyle = "unified" | "split"
+
+export interface SessionReviewTabProps {
+  title?: JSX.Element
+  empty?: JSX.Element
+  diffs: () => FileDiff[]
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+  diffStyle: DiffStyle
+  onDiffStyleChange?: (style: DiffStyle) => void
+  onViewFile?: (file: string) => void
+  onLineComment?: (comment: { file: string; selection: SelectedLineRange; comment: string; preview?: string }) => void
+  comments?: LineComment[]
+  focusedComment?: { file: string; id: string } | null
+  onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
+  focusedFile?: string
+  onScrollRef?: (el: HTMLDivElement) => void
+  classes?: {
+    root?: string
+    header?: string
+    container?: string
+  }
+}
+
+export function StickyAddButton(props: { children: JSX.Element }) {
+  const [stuck, setStuck] = createSignal(false)
+  let button: HTMLDivElement | undefined
+
+  createEffect(() => {
+    const node = button
+    if (!node) return
+
+    const scroll = node.parentElement
+    if (!scroll) return
+
+    const handler = () => {
+      const rect = node.getBoundingClientRect()
+      const scrollRect = scroll.getBoundingClientRect()
+      setStuck(rect.right >= scrollRect.right && scroll.scrollWidth > scroll.clientWidth)
+    }
+
+    scroll.addEventListener("scroll", handler, { passive: true })
+    const observer = new ResizeObserver(handler)
+    observer.observe(scroll)
+    handler()
+    onCleanup(() => {
+      scroll.removeEventListener("scroll", handler)
+      observer.disconnect()
+    })
+  })
+
+  return (
+    <div
+      ref={button}
+      class="bg-background-base h-full shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-border-weak-base px-3"
+      classList={{ "border-l": stuck() }}
+    >
+      {props.children}
+    </div>
+  )
+}
+
+export function SessionReviewTab(props: SessionReviewTabProps) {
+  let scroll: HTMLDivElement | undefined
+  let frame: number | undefined
+  let pending: { x: number; y: number } | undefined
+
+  const sdk = useSDK()
+
+  const readFile = async (path: string) => {
+    return sdk.client.file
+      .read({ path })
+      .then((x) => x.data)
+      .catch(() => undefined)
+  }
+
+  const restoreScroll = () => {
+    const el = scroll
+    if (!el) return
+
+    const s = props.view().scroll("review")
+    if (!s) return
+
+    if (el.scrollTop !== s.y) el.scrollTop = s.y
+    if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+  }
+
+  const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+    pending = {
+      x: event.currentTarget.scrollLeft,
+      y: event.currentTarget.scrollTop,
+    }
+    if (frame !== undefined) return
+
+    frame = requestAnimationFrame(() => {
+      frame = undefined
+
+      const next = pending
+      pending = undefined
+      if (!next) return
+
+      props.view().setScroll("review", next)
+    })
+  }
+
+  createEffect(
+    on(
+      () => props.diffs().length,
+      () => {
+        requestAnimationFrame(restoreScroll)
+      },
+      { defer: true },
+    ),
+  )
+
+  onCleanup(() => {
+    if (frame === undefined) return
+    cancelAnimationFrame(frame)
+  })
+
+  return (
+    <SessionReview
+      title={props.title}
+      empty={props.empty}
+      scrollRef={(el) => {
+        scroll = el
+        props.onScrollRef?.(el)
+        restoreScroll()
+      }}
+      onScroll={handleScroll}
+      onDiffRendered={() => requestAnimationFrame(restoreScroll)}
+      open={props.view().review.open()}
+      onOpenChange={props.view().review.setOpen}
+      classes={{
+        root: props.classes?.root ?? "pb-40",
+        header: props.classes?.header ?? "px-6",
+        container: props.classes?.container ?? "px-6",
+      }}
+      diffs={props.diffs()}
+      diffStyle={props.diffStyle}
+      onDiffStyleChange={props.onDiffStyleChange}
+      onViewFile={props.onViewFile}
+      focusedFile={props.focusedFile}
+      readFile={readFile}
+      onLineComment={props.onLineComment}
+      comments={props.comments}
+      focusedComment={props.focusedComment}
+      onFocusedCommentChange={props.onFocusedCommentChange}
+    />
+  )
+}

+ 10 - 0
packages/app/src/pages/session/session-command-helpers.ts

@@ -0,0 +1,10 @@
+export const canAddSelectionContext = (input: {
+  active?: string
+  pathFromTab: (tab: string) => string | undefined
+  selectedLines: (path: string) => unknown
+}) => {
+  if (!input.active) return false
+  const path = input.pathFromTab(input.active)
+  if (!path) return false
+  return input.selectedLines(path) != null
+}

+ 36 - 0
packages/app/src/pages/session/session-mobile-tabs.tsx

@@ -0,0 +1,36 @@
+import { Match, Show, Switch } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+
+export function SessionMobileTabs(props: {
+  open: boolean
+  hasReview: boolean
+  reviewCount: number
+  onSession: () => void
+  onChanges: () => void
+  t: (key: string, vars?: Record<string, string | number | boolean>) => string
+}) {
+  return (
+    <Show when={props.open}>
+      <Tabs class="h-auto">
+        <Tabs.List>
+          <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }} onClick={props.onSession}>
+            {props.t("session.tab.session")}
+          </Tabs.Trigger>
+          <Tabs.Trigger
+            value="changes"
+            class="w-1/2 !border-r-0"
+            classes={{ button: "w-full" }}
+            onClick={props.onChanges}
+          >
+            <Switch>
+              <Match when={props.hasReview}>
+                {props.t("session.review.filesChanged", { count: props.reviewCount })}
+              </Match>
+              <Match when={true}>{props.t("session.review.change.other")}</Match>
+            </Switch>
+          </Tabs.Trigger>
+        </Tabs.List>
+      </Tabs>
+    </Show>
+  )
+}

+ 22 - 0
packages/app/src/pages/session/session-prompt-dock.test.ts

@@ -0,0 +1,22 @@
+import { describe, expect, test } from "bun:test"
+import { questionSubtitle } from "./session-prompt-helpers"
+
+describe("questionSubtitle", () => {
+  const t = (key: string) => {
+    if (key === "ui.common.question.one") return "question"
+    if (key === "ui.common.question.other") return "questions"
+    return key
+  }
+
+  test("returns empty for zero", () => {
+    expect(questionSubtitle(0, t)).toBe("")
+  })
+
+  test("uses singular label", () => {
+    expect(questionSubtitle(1, t)).toBe("1 question")
+  })
+
+  test("uses plural label", () => {
+    expect(questionSubtitle(3, t)).toBe("3 questions")
+  })
+})

+ 137 - 0
packages/app/src/pages/session/session-prompt-dock.tsx

@@ -0,0 +1,137 @@
+import { For, Show, type ComponentProps } from "solid-js"
+import { Button } from "@opencode-ai/ui/button"
+import { BasicTool } from "@opencode-ai/ui/basic-tool"
+import { PromptInput } from "@/components/prompt-input"
+import { QuestionDock } from "@/components/question-dock"
+import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
+
+const questionDockRequest = (value: unknown) => value as ComponentProps<typeof QuestionDock>["request"]
+
+export function SessionPromptDock(props: {
+  centered: boolean
+  questionRequest: () => { questions: unknown[] } | undefined
+  permissionRequest: () => { patterns: string[]; permission: string } | undefined
+  blocked: boolean
+  promptReady: boolean
+  handoffPrompt?: string
+  t: (key: string, vars?: Record<string, string | number | boolean>) => string
+  responding: boolean
+  onDecide: (response: "once" | "always" | "reject") => void
+  inputRef: (el: HTMLDivElement) => void
+  newSessionWorktree: string
+  onNewSessionWorktreeReset: () => void
+  onSubmit: () => void
+  setPromptDockRef: (el: HTMLDivElement) => void
+}) {
+  return (
+    <div
+      ref={props.setPromptDockRef}
+      class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
+    >
+      <div
+        classList={{
+          "w-full px-4 pointer-events-auto": true,
+          "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered,
+        }}
+      >
+        <Show when={props.questionRequest()} keyed>
+          {(req) => {
+            const subtitle = questionSubtitle(req.questions.length, (key) => props.t(key))
+            return (
+              <div data-component="tool-part-wrapper" data-question="true" class="mb-3">
+                <BasicTool
+                  icon="bubble-5"
+                  locked
+                  defaultOpen
+                  trigger={{
+                    title: props.t("ui.tool.questions"),
+                    subtitle,
+                  }}
+                />
+                <QuestionDock request={questionDockRequest(req)} />
+              </div>
+            )
+          }}
+        </Show>
+
+        <Show when={props.permissionRequest()} keyed>
+          {(perm) => (
+            <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
+              <BasicTool
+                icon="checklist"
+                locked
+                defaultOpen
+                trigger={{
+                  title: props.t("notification.permission.title"),
+                  subtitle:
+                    perm.permission === "doom_loop"
+                      ? props.t("settings.permissions.tool.doom_loop.title")
+                      : perm.permission,
+                }}
+              >
+                <Show when={perm.patterns.length > 0}>
+                  <div class="flex flex-col gap-1 py-2 px-3 max-h-40 overflow-y-auto no-scrollbar">
+                    <For each={perm.patterns}>
+                      {(pattern) => <code class="text-12-regular text-text-base break-all">{pattern}</code>}
+                    </For>
+                  </div>
+                </Show>
+                <Show when={perm.permission === "doom_loop"}>
+                  <div class="text-12-regular text-text-weak pb-2 px-3">
+                    {props.t("settings.permissions.tool.doom_loop.description")}
+                  </div>
+                </Show>
+              </BasicTool>
+              <div data-component="permission-prompt">
+                <div data-slot="permission-actions">
+                  <Button
+                    variant="ghost"
+                    size="small"
+                    onClick={() => props.onDecide("reject")}
+                    disabled={props.responding}
+                  >
+                    {props.t("ui.permission.deny")}
+                  </Button>
+                  <Button
+                    variant="secondary"
+                    size="small"
+                    onClick={() => props.onDecide("always")}
+                    disabled={props.responding}
+                  >
+                    {props.t("ui.permission.allowAlways")}
+                  </Button>
+                  <Button
+                    variant="primary"
+                    size="small"
+                    onClick={() => props.onDecide("once")}
+                    disabled={props.responding}
+                  >
+                    {props.t("ui.permission.allowOnce")}
+                  </Button>
+                </div>
+              </div>
+            </div>
+          )}
+        </Show>
+
+        <Show when={!props.blocked}>
+          <Show
+            when={props.promptReady}
+            fallback={
+              <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
+                {props.handoffPrompt || props.t("prompt.loading")}
+              </div>
+            }
+          >
+            <PromptInput
+              ref={props.inputRef}
+              newSessionWorktree={props.newSessionWorktree}
+              onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+              onSubmit={props.onSubmit}
+            />
+          </Show>
+        </Show>
+      </div>
+    </div>
+  )
+}

+ 4 - 0
packages/app/src/pages/session/session-prompt-helpers.ts

@@ -0,0 +1,4 @@
+export const questionSubtitle = (count: number, t: (key: string) => string) => {
+  if (count === 0) return ""
+  return `${count} ${t(count > 1 ? "ui.common.question.other" : "ui.common.question.one")}`
+}

+ 306 - 0
packages/app/src/pages/session/session-side-panel.tsx

@@ -0,0 +1,306 @@
+import { For, Match, Show, Switch, createMemo, onCleanup, type JSX, type ValidComponent } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Mark } from "@opencode-ai/ui/logo"
+import FileTree from "@/components/file-tree"
+import { SessionContextUsage } from "@/components/session-context-usage"
+import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
+import { FileTabContent } from "@/pages/session/file-tabs"
+import { StickyAddButton } from "@/pages/session/review-tab"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { useComments } from "@/context/comments"
+import { useCommand } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useFile, type SelectedLineRange } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useSync } from "@/context/sync"
+
+export function SessionSidePanel(props: {
+  open: boolean
+  language: ReturnType<typeof useLanguage>
+  layout: ReturnType<typeof useLayout>
+  command: ReturnType<typeof useCommand>
+  dialog: ReturnType<typeof useDialog>
+  file: ReturnType<typeof useFile>
+  comments: ReturnType<typeof useComments>
+  sync: ReturnType<typeof useSync>
+  hasReview: boolean
+  reviewCount: number
+  reviewTab: boolean
+  contextOpen: () => boolean
+  openedTabs: () => string[]
+  activeTab: () => string
+  activeFileTab: () => string | undefined
+  tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+  openTab: (value: string) => void
+  showAllFiles: () => void
+  reviewPanel: () => JSX.Element
+  messages: () => unknown[]
+  visibleUserMessages: () => unknown[]
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+  info: () => unknown
+  handoffFiles: () => Record<string, SelectedLineRange | null> | undefined
+  codeComponent: NonNullable<ValidComponent>
+  addCommentToContext: (input: {
+    file: string
+    selection: SelectedLineRange
+    comment: string
+    preview?: string
+    origin?: "review" | "file"
+  }) => void
+  activeDraggable: () => string | undefined
+  onDragStart: (event: unknown) => void
+  onDragEnd: () => void
+  onDragOver: (event: DragEvent) => void
+  fileTreeTab: () => "changes" | "all"
+  setFileTreeTabValue: (value: string) => void
+  diffsReady: boolean
+  diffFiles: string[]
+  kinds: Map<string, "add" | "del" | "mix">
+  activeDiff?: string
+  focusReviewDiff: (path: string) => void
+}) {
+  return (
+    <Show when={props.open}>
+      <aside
+        id="review-panel"
+        aria-label={props.language.t("session.panel.reviewAndFiles")}
+        class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
+      >
+        <div class="flex-1 min-w-0 h-full">
+          <Show
+            when={props.layout.fileTree.opened() && props.fileTreeTab() === "changes"}
+            fallback={
+              <DragDropProvider
+                onDragStart={props.onDragStart}
+                onDragEnd={props.onDragEnd}
+                onDragOver={props.onDragOver}
+                collisionDetector={closestCenter}
+              >
+                <DragDropSensors />
+                <ConstrainDragYAxis />
+                <Tabs value={props.activeTab()} onChange={props.openTab}>
+                  <div class="sticky top-0 shrink-0 flex">
+                    <Tabs.List
+                      ref={(el: HTMLDivElement) => {
+                        const stop = createFileTabListSync({ el, contextOpen: props.contextOpen })
+                        onCleanup(stop)
+                      }}
+                    >
+                      <Show when={props.reviewTab}>
+                        <Tabs.Trigger value="review" classes={{ button: "!pl-6" }}>
+                          <div class="flex items-center gap-1.5">
+                            <div>{props.language.t("session.tab.review")}</div>
+                            <Show when={props.hasReview}>
+                              <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+                                {props.reviewCount}
+                              </div>
+                            </Show>
+                          </div>
+                        </Tabs.Trigger>
+                      </Show>
+                      <Show when={props.contextOpen()}>
+                        <Tabs.Trigger
+                          value="context"
+                          closeButton={
+                            <Tooltip value={props.language.t("common.closeTab")} placement="bottom">
+                              <IconButton
+                                icon="close-small"
+                                variant="ghost"
+                                class="h-5 w-5"
+                                onClick={() => props.tabs().close("context")}
+                                aria-label={props.language.t("common.closeTab")}
+                              />
+                            </Tooltip>
+                          }
+                          hideCloseButton
+                          onMiddleClick={() => props.tabs().close("context")}
+                        >
+                          <div class="flex items-center gap-2">
+                            <SessionContextUsage variant="indicator" />
+                            <div>{props.language.t("session.tab.context")}</div>
+                          </div>
+                        </Tabs.Trigger>
+                      </Show>
+                      <SortableProvider ids={props.openedTabs()}>
+                        <For each={props.openedTabs()}>
+                          {(tab) => <SortableTab tab={tab} onTabClose={props.tabs().close} />}
+                        </For>
+                      </SortableProvider>
+                      <StickyAddButton>
+                        <TooltipKeybind
+                          title={props.language.t("command.file.open")}
+                          keybind={props.command.keybind("file.open")}
+                          class="flex items-center"
+                        >
+                          <IconButton
+                            icon="plus-small"
+                            variant="ghost"
+                            iconSize="large"
+                            onClick={() =>
+                              props.dialog.show(() => <DialogSelectFile mode="files" onOpenFile={props.showAllFiles} />)
+                            }
+                            aria-label={props.language.t("command.file.open")}
+                          />
+                        </TooltipKeybind>
+                      </StickyAddButton>
+                    </Tabs.List>
+                  </div>
+
+                  <Show when={props.reviewTab}>
+                    <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+                      <Show when={props.activeTab() === "review"}>{props.reviewPanel()}</Show>
+                    </Tabs.Content>
+                  </Show>
+
+                  <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+                    <Show when={props.activeTab() === "empty"}>
+                      <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                        <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+                          <Mark class="w-14 opacity-10" />
+                          <div class="text-14-regular text-text-weak max-w-56">
+                            {props.language.t("session.files.selectToOpen")}
+                          </div>
+                        </div>
+                      </div>
+                    </Show>
+                  </Tabs.Content>
+
+                  <Show when={props.contextOpen()}>
+                    <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                      <Show when={props.activeTab() === "context"}>
+                        <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                          <SessionContextTab
+                            messages={props.messages as never}
+                            visibleUserMessages={props.visibleUserMessages as never}
+                            view={props.view as never}
+                            info={props.info as never}
+                          />
+                        </div>
+                      </Show>
+                    </Tabs.Content>
+                  </Show>
+
+                  <Show when={props.activeFileTab()} keyed>
+                    {(tab) => (
+                      <FileTabContent
+                        tab={tab}
+                        activeTab={props.activeTab}
+                        tabs={props.tabs}
+                        view={props.view}
+                        handoffFiles={props.handoffFiles}
+                        file={props.file}
+                        comments={props.comments}
+                        language={props.language}
+                        codeComponent={props.codeComponent}
+                        addCommentToContext={props.addCommentToContext}
+                      />
+                    )}
+                  </Show>
+                </Tabs>
+                <DragOverlay>
+                  <Show when={props.activeDraggable()}>
+                    {(tab) => {
+                      const path = createMemo(() => props.file.pathFromTab(tab()))
+                      return (
+                        <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+                          <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+                        </div>
+                      )
+                    }}
+                  </Show>
+                </DragOverlay>
+              </DragDropProvider>
+            }
+          >
+            {props.reviewPanel()}
+          </Show>
+        </div>
+
+        <Show when={props.layout.fileTree.opened()}>
+          <div
+            id="file-tree-panel"
+            class="relative shrink-0 h-full"
+            style={{ width: `${props.layout.fileTree.width()}px` }}
+          >
+            <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden group/filetree">
+              <Tabs
+                variant="pill"
+                value={props.fileTreeTab()}
+                onChange={props.setFileTreeTabValue}
+                class="h-full"
+                data-scope="filetree"
+              >
+                <Tabs.List>
+                  <Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
+                    {props.reviewCount}{" "}
+                    {props.language.t(
+                      props.reviewCount === 1 ? "session.review.change.one" : "session.review.change.other",
+                    )}
+                  </Tabs.Trigger>
+                  <Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
+                    {props.language.t("session.files.all")}
+                  </Tabs.Trigger>
+                </Tabs.List>
+                <Tabs.Content value="changes" class="bg-background-base px-3 py-0">
+                  <Switch>
+                    <Match when={props.hasReview}>
+                      <Show
+                        when={props.diffsReady}
+                        fallback={
+                          <div class="px-2 py-2 text-12-regular text-text-weak">
+                            {props.language.t("common.loading")}
+                            {props.language.t("common.loading.ellipsis")}
+                          </div>
+                        }
+                      >
+                        <FileTree
+                          path=""
+                          allowed={props.diffFiles}
+                          kinds={props.kinds}
+                          draggable={false}
+                          active={props.activeDiff}
+                          onFileClick={(node) => props.focusReviewDiff(node.path)}
+                        />
+                      </Show>
+                    </Match>
+                    <Match when={true}>
+                      <div class="mt-8 text-center text-12-regular text-text-weak">
+                        {props.language.t("session.review.noChanges")}
+                      </div>
+                    </Match>
+                  </Switch>
+                </Tabs.Content>
+                <Tabs.Content value="all" class="bg-background-base px-3 py-0">
+                  <FileTree
+                    path=""
+                    modified={props.diffFiles}
+                    kinds={props.kinds}
+                    onFileClick={(node) => props.openTab(props.file.tab(node.path))}
+                  />
+                </Tabs.Content>
+              </Tabs>
+            </div>
+            <ResizeHandle
+              direction="horizontal"
+              edge="start"
+              size={props.layout.fileTree.width()}
+              min={200}
+              max={480}
+              collapseThreshold={160}
+              onResize={props.layout.fileTree.resize}
+              onCollapse={props.layout.fileTree.close}
+            />
+          </div>
+        </Show>
+      </aside>
+    </Show>
+  )
+}

+ 16 - 0
packages/app/src/pages/session/terminal-label.ts

@@ -0,0 +1,16 @@
+export const terminalTabLabel = (input: {
+  title?: string
+  titleNumber?: number
+  t: (key: string, vars?: Record<string, string | number | boolean>) => string
+}) => {
+  const title = input.title ?? ""
+  const number = input.titleNumber ?? 0
+  const match = title.match(/^Terminal (\d+)$/)
+  const parsed = match ? Number(match[1]) : undefined
+  const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number
+
+  if (title && !isDefaultTitle) return title
+  if (number > 0) return input.t("terminal.title.numbered", { number })
+  if (title) return title
+  return input.t("terminal.title")
+}

+ 25 - 0
packages/app/src/pages/session/terminal-panel.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { terminalTabLabel } from "./terminal-label"
+
+const t = (key: string, vars?: Record<string, string | number | boolean>) => {
+  if (key === "terminal.title.numbered") return `Terminal ${vars?.number}`
+  if (key === "terminal.title") return "Terminal"
+  return key
+}
+
+describe("terminalTabLabel", () => {
+  test("returns custom title unchanged", () => {
+    const label = terminalTabLabel({ title: "server", titleNumber: 3, t })
+    expect(label).toBe("server")
+  })
+
+  test("normalizes default numbered title", () => {
+    const label = terminalTabLabel({ title: "Terminal 2", titleNumber: 2, t })
+    expect(label).toBe("Terminal 2")
+  })
+
+  test("falls back to generic title", () => {
+    const label = terminalTabLabel({ title: "", titleNumber: 0, t })
+    expect(label).toBe("Terminal")
+  })
+})

+ 169 - 0
packages/app/src/pages/session/terminal-panel.tsx

@@ -0,0 +1,169 @@
+import { createMemo, For, Show } from "solid-js"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { ConstrainDragYAxis } from "@/utils/solid-dnd"
+import { SortableTerminalTab } from "@/components/session"
+import { Terminal } from "@/components/terminal"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+
+export function TerminalPanel(props: {
+  open: boolean
+  height: number
+  resize: (value: number) => void
+  close: () => void
+  terminal: ReturnType<typeof useTerminal>
+  language: ReturnType<typeof useLanguage>
+  command: ReturnType<typeof useCommand>
+  handoff: () => string[]
+  activeTerminalDraggable: () => string | undefined
+  handleTerminalDragStart: (event: unknown) => void
+  handleTerminalDragOver: (event: DragEvent) => void
+  handleTerminalDragEnd: () => void
+  onCloseTab: () => void
+}) {
+  return (
+    <Show when={props.open}>
+      <div
+        id="terminal-panel"
+        role="region"
+        aria-label={props.language.t("terminal.title")}
+        class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+        style={{ height: `${props.height}px` }}
+      >
+        <ResizeHandle
+          direction="vertical"
+          size={props.height}
+          min={100}
+          max={window.innerHeight * 0.6}
+          collapseThreshold={50}
+          onResize={props.resize}
+          onCollapse={props.close}
+        />
+        <Show
+          when={props.terminal.ready()}
+          fallback={
+            <div class="flex flex-col h-full pointer-events-none">
+              <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
+                <For each={props.handoff()}>
+                  {(title) => (
+                    <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
+                      {title}
+                    </div>
+                  )}
+                </For>
+                <div class="flex-1" />
+                <div class="text-text-weak pr-2">
+                  {props.language.t("common.loading")}
+                  {props.language.t("common.loading.ellipsis")}
+                </div>
+              </div>
+              <div class="flex-1 flex items-center justify-center text-text-weak">
+                {props.language.t("terminal.loading")}
+              </div>
+            </div>
+          }
+        >
+          <DragDropProvider
+            onDragStart={props.handleTerminalDragStart}
+            onDragEnd={props.handleTerminalDragEnd}
+            onDragOver={props.handleTerminalDragOver}
+            collisionDetector={closestCenter}
+          >
+            <DragDropSensors />
+            <ConstrainDragYAxis />
+            <div class="flex flex-col h-full">
+              <Tabs
+                variant="alt"
+                value={props.terminal.active()}
+                onChange={(id) => props.terminal.open(id)}
+                class="!h-auto !flex-none"
+              >
+                <Tabs.List class="h-10">
+                  <SortableProvider ids={props.terminal.all().map((t: LocalPTY) => t.id)}>
+                    <For each={props.terminal.all()}>
+                      {(pty) => (
+                        <SortableTerminalTab
+                          terminal={pty}
+                          onClose={() => {
+                            props.close()
+                            props.onCloseTab()
+                          }}
+                        />
+                      )}
+                    </For>
+                  </SortableProvider>
+                  <div class="h-full flex items-center justify-center">
+                    <TooltipKeybind
+                      title={props.language.t("command.terminal.new")}
+                      keybind={props.command.keybind("terminal.new")}
+                      class="flex items-center"
+                    >
+                      <IconButton
+                        icon="plus-small"
+                        variant="ghost"
+                        iconSize="large"
+                        onClick={props.terminal.new}
+                        aria-label={props.language.t("command.terminal.new")}
+                      />
+                    </TooltipKeybind>
+                  </div>
+                </Tabs.List>
+              </Tabs>
+              <div class="flex-1 min-h-0 relative">
+                <For each={props.terminal.all()}>
+                  {(pty) => (
+                    <div
+                      id={`terminal-wrapper-${pty.id}`}
+                      class="absolute inset-0"
+                      style={{
+                        display: props.terminal.active() === pty.id ? "block" : "none",
+                      }}
+                    >
+                      <Show when={pty.id} keyed>
+                        <Terminal
+                          pty={pty}
+                          onCleanup={props.terminal.update}
+                          onConnectError={() => props.terminal.clone(pty.id)}
+                        />
+                      </Show>
+                    </div>
+                  )}
+                </For>
+              </div>
+            </div>
+            <DragOverlay>
+              <Show when={props.activeTerminalDraggable()}>
+                {(draggedId) => {
+                  const pty = createMemo(() => props.terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+                  return (
+                    <Show when={pty()}>
+                      {(t) => (
+                        <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+                          {terminalTabLabel({
+                            title: t().title,
+                            titleNumber: t().titleNumber,
+                            t: props.language.t as (
+                              key: string,
+                              vars?: Record<string, string | number | boolean>,
+                            ) => string,
+                          })}
+                        </div>
+                      )}
+                    </Show>
+                  )
+                }}
+              </Show>
+            </DragOverlay>
+          </DragDropProvider>
+        </Show>
+      </div>
+    </Show>
+  )
+}

+ 44 - 0
packages/app/src/pages/session/use-session-commands.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import { canAddSelectionContext } from "./session-command-helpers"
+
+describe("canAddSelectionContext", () => {
+  test("returns false without active tab", () => {
+    expect(
+      canAddSelectionContext({
+        active: undefined,
+        pathFromTab: () => "src/a.ts",
+        selectedLines: () => ({ start: 1, end: 1 }),
+      }),
+    ).toBe(false)
+  })
+
+  test("returns false when active tab is not a file", () => {
+    expect(
+      canAddSelectionContext({
+        active: "context",
+        pathFromTab: () => undefined,
+        selectedLines: () => ({ start: 1, end: 1 }),
+      }),
+    ).toBe(false)
+  })
+
+  test("returns false without selected lines", () => {
+    expect(
+      canAddSelectionContext({
+        active: "file://src/a.ts",
+        pathFromTab: () => "src/a.ts",
+        selectedLines: () => null,
+      }),
+    ).toBe(false)
+  })
+
+  test("returns true when file and selection exist", () => {
+    expect(
+      canAddSelectionContext({
+        active: "file://src/a.ts",
+        pathFromTab: () => "src/a.ts",
+        selectedLines: () => ({ start: 1, end: 2 }),
+      }),
+    ).toBe(true)
+  })
+})

+ 439 - 0
packages/app/src/pages/session/use-session-commands.tsx

@@ -0,0 +1,439 @@
+import { createMemo } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { useCommand } from "@/context/command"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useFile, selectionFromLines, type FileSelection } from "@/context/file"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
+import { usePermission } from "@/context/permission"
+import { usePrompt } from "@/context/prompt"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
+import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { DialogSelectMcp } from "@/components/dialog-select-mcp"
+import { DialogFork } from "@/components/dialog-fork"
+import { showToast } from "@opencode-ai/ui/toast"
+import { findLast } from "@opencode-ai/util/array"
+import { extractPromptFromParts } from "@/utils/prompt"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+import { combineCommandSections } from "@/pages/session/helpers"
+import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
+
+export const useSessionCommands = (input: {
+  command: ReturnType<typeof useCommand>
+  dialog: ReturnType<typeof useDialog>
+  file: ReturnType<typeof useFile>
+  language: ReturnType<typeof useLanguage>
+  local: ReturnType<typeof useLocal>
+  permission: ReturnType<typeof usePermission>
+  prompt: ReturnType<typeof usePrompt>
+  sdk: ReturnType<typeof useSDK>
+  sync: ReturnType<typeof useSync>
+  terminal: ReturnType<typeof useTerminal>
+  layout: ReturnType<typeof useLayout>
+  params: ReturnType<typeof useParams>
+  navigate: ReturnType<typeof useNavigate>
+  tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
+  view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
+  info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
+  status: () => { type: string }
+  userMessages: () => UserMessage[]
+  visibleUserMessages: () => UserMessage[]
+  activeMessage: () => UserMessage | undefined
+  showAllFiles: () => void
+  navigateMessageByOffset: (offset: number) => void
+  setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
+  setActiveMessage: (message: UserMessage | undefined) => void
+  addSelectionToContext: (path: string, selection: FileSelection) => void
+}) => {
+  const sessionCommands = createMemo(() => [
+    {
+      id: "session.new",
+      title: input.language.t("command.session.new"),
+      category: input.language.t("command.category.session"),
+      keybind: "mod+shift+s",
+      slash: "new",
+      onSelect: () => input.navigate(`/${input.params.dir}/session`),
+    },
+  ])
+
+  const fileCommands = createMemo(() => [
+    {
+      id: "file.open",
+      title: input.language.t("command.file.open"),
+      description: input.language.t("palette.search.placeholder"),
+      category: input.language.t("command.category.file"),
+      keybind: "mod+p",
+      slash: "open",
+      onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
+    },
+    {
+      id: "tab.close",
+      title: input.language.t("command.tab.close"),
+      category: input.language.t("command.category.file"),
+      keybind: "mod+w",
+      disabled: !input.tabs().active(),
+      onSelect: () => {
+        const active = input.tabs().active()
+        if (!active) return
+        input.tabs().close(active)
+      },
+    },
+  ])
+
+  const contextCommands = createMemo(() => [
+    {
+      id: "context.addSelection",
+      title: input.language.t("command.context.addSelection"),
+      description: input.language.t("command.context.addSelection.description"),
+      category: input.language.t("command.category.context"),
+      keybind: "mod+shift+l",
+      disabled: !canAddSelectionContext({
+        active: input.tabs().active(),
+        pathFromTab: input.file.pathFromTab,
+        selectedLines: input.file.selectedLines,
+      }),
+      onSelect: () => {
+        const active = input.tabs().active()
+        if (!active) return
+        const path = input.file.pathFromTab(active)
+        if (!path) return
+
+        const range = input.file.selectedLines(path)
+        if (!range) {
+          showToast({
+            title: input.language.t("toast.context.noLineSelection.title"),
+            description: input.language.t("toast.context.noLineSelection.description"),
+          })
+          return
+        }
+
+        input.addSelectionToContext(path, selectionFromLines(range))
+      },
+    },
+  ])
+
+  const viewCommands = createMemo(() => [
+    {
+      id: "terminal.toggle",
+      title: input.language.t("command.terminal.toggle"),
+      description: "",
+      category: input.language.t("command.category.view"),
+      keybind: "ctrl+`",
+      slash: "terminal",
+      onSelect: () => input.view().terminal.toggle(),
+    },
+    {
+      id: "review.toggle",
+      title: input.language.t("command.review.toggle"),
+      description: "",
+      category: input.language.t("command.category.view"),
+      keybind: "mod+shift+r",
+      onSelect: () => input.view().reviewPanel.toggle(),
+    },
+    {
+      id: "fileTree.toggle",
+      title: input.language.t("command.fileTree.toggle"),
+      description: "",
+      category: input.language.t("command.category.view"),
+      onSelect: () => {
+        const opening = !input.layout.fileTree.opened()
+        if (opening && !input.view().reviewPanel.opened()) input.view().reviewPanel.open()
+        input.layout.fileTree.toggle()
+      },
+    },
+    {
+      id: "terminal.new",
+      title: input.language.t("command.terminal.new"),
+      description: input.language.t("command.terminal.new.description"),
+      category: input.language.t("command.category.terminal"),
+      keybind: "ctrl+alt+t",
+      onSelect: () => {
+        if (input.terminal.all().length > 0) input.terminal.new()
+        input.view().terminal.open()
+      },
+    },
+    {
+      id: "steps.toggle",
+      title: input.language.t("command.steps.toggle"),
+      description: input.language.t("command.steps.toggle.description"),
+      category: input.language.t("command.category.view"),
+      keybind: "mod+e",
+      slash: "steps",
+      disabled: !input.params.id,
+      onSelect: () => {
+        const msg = input.activeMessage()
+        if (!msg) return
+        input.setExpanded(msg.id, (open: boolean | undefined) => !open)
+      },
+    },
+  ])
+
+  const messageCommands = createMemo(() => [
+    {
+      id: "message.previous",
+      title: input.language.t("command.message.previous"),
+      description: input.language.t("command.message.previous.description"),
+      category: input.language.t("command.category.session"),
+      keybind: "mod+arrowup",
+      disabled: !input.params.id,
+      onSelect: () => input.navigateMessageByOffset(-1),
+    },
+    {
+      id: "message.next",
+      title: input.language.t("command.message.next"),
+      description: input.language.t("command.message.next.description"),
+      category: input.language.t("command.category.session"),
+      keybind: "mod+arrowdown",
+      disabled: !input.params.id,
+      onSelect: () => input.navigateMessageByOffset(1),
+    },
+  ])
+
+  const agentCommands = createMemo(() => [
+    {
+      id: "model.choose",
+      title: input.language.t("command.model.choose"),
+      description: input.language.t("command.model.choose.description"),
+      category: input.language.t("command.category.model"),
+      keybind: "mod+'",
+      slash: "model",
+      onSelect: () => input.dialog.show(() => <DialogSelectModel />),
+    },
+    {
+      id: "mcp.toggle",
+      title: input.language.t("command.mcp.toggle"),
+      description: input.language.t("command.mcp.toggle.description"),
+      category: input.language.t("command.category.mcp"),
+      keybind: "mod+;",
+      slash: "mcp",
+      onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
+    },
+    {
+      id: "agent.cycle",
+      title: input.language.t("command.agent.cycle"),
+      description: input.language.t("command.agent.cycle.description"),
+      category: input.language.t("command.category.agent"),
+      keybind: "mod+.",
+      slash: "agent",
+      onSelect: () => input.local.agent.move(1),
+    },
+    {
+      id: "agent.cycle.reverse",
+      title: input.language.t("command.agent.cycle.reverse"),
+      description: input.language.t("command.agent.cycle.reverse.description"),
+      category: input.language.t("command.category.agent"),
+      keybind: "shift+mod+.",
+      onSelect: () => input.local.agent.move(-1),
+    },
+    {
+      id: "model.variant.cycle",
+      title: input.language.t("command.model.variant.cycle"),
+      description: input.language.t("command.model.variant.cycle.description"),
+      category: input.language.t("command.category.model"),
+      keybind: "shift+mod+d",
+      onSelect: () => {
+        input.local.model.variant.cycle()
+      },
+    },
+  ])
+
+  const permissionCommands = createMemo(() => [
+    {
+      id: "permissions.autoaccept",
+      title:
+        input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
+          ? input.language.t("command.permissions.autoaccept.disable")
+          : input.language.t("command.permissions.autoaccept.enable"),
+      category: input.language.t("command.category.permissions"),
+      keybind: "mod+shift+a",
+      disabled: !input.params.id || !input.permission.permissionsEnabled(),
+      onSelect: () => {
+        const sessionID = input.params.id
+        if (!sessionID) return
+        input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
+        showToast({
+          title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+            ? input.language.t("toast.permissions.autoaccept.on.title")
+            : input.language.t("toast.permissions.autoaccept.off.title"),
+          description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
+            ? input.language.t("toast.permissions.autoaccept.on.description")
+            : input.language.t("toast.permissions.autoaccept.off.description"),
+        })
+      },
+    },
+  ])
+
+  const sessionActionCommands = createMemo(() => [
+    {
+      id: "session.undo",
+      title: input.language.t("command.session.undo"),
+      description: input.language.t("command.session.undo.description"),
+      category: input.language.t("command.category.session"),
+      slash: "undo",
+      disabled: !input.params.id || input.visibleUserMessages().length === 0,
+      onSelect: async () => {
+        const sessionID = input.params.id
+        if (!sessionID) return
+        if (input.status()?.type !== "idle") {
+          await input.sdk.client.session.abort({ sessionID }).catch(() => {})
+        }
+        const revert = input.info()?.revert?.messageID
+        const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
+        if (!message) return
+        await input.sdk.client.session.revert({ sessionID, messageID: message.id })
+        const parts = input.sync.data.part[message.id]
+        if (parts) {
+          const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
+          input.prompt.set(restored)
+        }
+        const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
+        input.setActiveMessage(priorMessage)
+      },
+    },
+    {
+      id: "session.redo",
+      title: input.language.t("command.session.redo"),
+      description: input.language.t("command.session.redo.description"),
+      category: input.language.t("command.category.session"),
+      slash: "redo",
+      disabled: !input.params.id || !input.info()?.revert?.messageID,
+      onSelect: async () => {
+        const sessionID = input.params.id
+        if (!sessionID) return
+        const revertMessageID = input.info()?.revert?.messageID
+        if (!revertMessageID) return
+        const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
+        if (!nextMessage) {
+          await input.sdk.client.session.unrevert({ sessionID })
+          input.prompt.reset()
+          const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
+          input.setActiveMessage(lastMsg)
+          return
+        }
+        await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+        const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
+        input.setActiveMessage(priorMsg)
+      },
+    },
+    {
+      id: "session.compact",
+      title: input.language.t("command.session.compact"),
+      description: input.language.t("command.session.compact.description"),
+      category: input.language.t("command.category.session"),
+      slash: "compact",
+      disabled: !input.params.id || input.visibleUserMessages().length === 0,
+      onSelect: async () => {
+        const sessionID = input.params.id
+        if (!sessionID) return
+        const model = input.local.model.current()
+        if (!model) {
+          showToast({
+            title: input.language.t("toast.model.none.title"),
+            description: input.language.t("toast.model.none.description"),
+          })
+          return
+        }
+        await input.sdk.client.session.summarize({
+          sessionID,
+          modelID: model.id,
+          providerID: model.provider.id,
+        })
+      },
+    },
+    {
+      id: "session.fork",
+      title: input.language.t("command.session.fork"),
+      description: input.language.t("command.session.fork.description"),
+      category: input.language.t("command.category.session"),
+      slash: "fork",
+      disabled: !input.params.id || input.visibleUserMessages().length === 0,
+      onSelect: () => input.dialog.show(() => <DialogFork />),
+    },
+  ])
+
+  const shareCommands = createMemo(() => {
+    if (input.sync.data.config.share === "disabled") return []
+    return [
+      {
+        id: "session.share",
+        title: input.language.t("command.session.share"),
+        description: input.language.t("command.session.share.description"),
+        category: input.language.t("command.category.session"),
+        slash: "share",
+        disabled: !input.params.id || !!input.info()?.share?.url,
+        onSelect: async () => {
+          if (!input.params.id) return
+          await input.sdk.client.session
+            .share({ sessionID: input.params.id })
+            .then((res) => {
+              navigator.clipboard.writeText(res.data!.share!.url).catch(() =>
+                showToast({
+                  title: input.language.t("toast.session.share.copyFailed.title"),
+                  variant: "error",
+                }),
+              )
+            })
+            .then(() =>
+              showToast({
+                title: input.language.t("toast.session.share.success.title"),
+                description: input.language.t("toast.session.share.success.description"),
+                variant: "success",
+              }),
+            )
+            .catch(() =>
+              showToast({
+                title: input.language.t("toast.session.share.failed.title"),
+                description: input.language.t("toast.session.share.failed.description"),
+                variant: "error",
+              }),
+            )
+        },
+      },
+      {
+        id: "session.unshare",
+        title: input.language.t("command.session.unshare"),
+        description: input.language.t("command.session.unshare.description"),
+        category: input.language.t("command.category.session"),
+        slash: "unshare",
+        disabled: !input.params.id || !input.info()?.share?.url,
+        onSelect: async () => {
+          if (!input.params.id) return
+          await input.sdk.client.session
+            .unshare({ sessionID: input.params.id })
+            .then(() =>
+              showToast({
+                title: input.language.t("toast.session.unshare.success.title"),
+                description: input.language.t("toast.session.unshare.success.description"),
+                variant: "success",
+              }),
+            )
+            .catch(() =>
+              showToast({
+                title: input.language.t("toast.session.unshare.failed.title"),
+                description: input.language.t("toast.session.unshare.failed.description"),
+                variant: "error",
+              }),
+            )
+        },
+      },
+    ]
+  })
+
+  input.command.register("session", () =>
+    combineCommandSections([
+      sessionCommands(),
+      fileCommands(),
+      contextCommands(),
+      viewCommands(),
+      messageCommands(),
+      agentCommands(),
+      permissionCommands(),
+      sessionActionCommands(),
+      shareCommands(),
+    ]),
+  )
+}

+ 16 - 0
packages/app/src/pages/session/use-session-hash-scroll.test.ts

@@ -0,0 +1,16 @@
+import { describe, expect, test } from "bun:test"
+import { messageIdFromHash } from "./use-session-hash-scroll"
+
+describe("messageIdFromHash", () => {
+  test("parses hash with leading #", () => {
+    expect(messageIdFromHash("#message-abc123")).toBe("abc123")
+  })
+
+  test("parses raw hash fragment", () => {
+    expect(messageIdFromHash("message-42")).toBe("42")
+  })
+
+  test("ignores non-message anchors", () => {
+    expect(messageIdFromHash("#review-panel")).toBeUndefined()
+  })
+})

+ 174 - 0
packages/app/src/pages/session/use-session-hash-scroll.ts

@@ -0,0 +1,174 @@
+import { createEffect, on, onCleanup } from "solid-js"
+import { UserMessage } from "@opencode-ai/sdk/v2"
+
+export const messageIdFromHash = (hash: string) => {
+  const value = hash.startsWith("#") ? hash.slice(1) : hash
+  const match = value.match(/^message-(.+)$/)
+  if (!match) return
+  return match[1]
+}
+
+export const useSessionHashScroll = (input: {
+  sessionKey: () => string
+  sessionID: () => string | undefined
+  messagesReady: () => boolean
+  visibleUserMessages: () => UserMessage[]
+  turnStart: () => number
+  currentMessageId: () => string | undefined
+  pendingMessage: () => string | undefined
+  setPendingMessage: (value: string | undefined) => void
+  setActiveMessage: (message: UserMessage | undefined) => void
+  setTurnStart: (value: number) => void
+  scheduleTurnBackfill: () => void
+  autoScroll: { pause: () => void; forceScrollToBottom: () => void }
+  scroller: () => HTMLDivElement | undefined
+  anchor: (id: string) => string
+  scheduleScrollState: (el: HTMLDivElement) => void
+  consumePendingMessage: (key: string) => string | undefined
+}) => {
+  const clearMessageHash = () => {
+    if (!window.location.hash) return
+    window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
+  }
+
+  const updateHash = (id: string) => {
+    window.history.replaceState(null, "", `#${input.anchor(id)}`)
+  }
+
+  const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
+    const root = input.scroller()
+    if (!root) return false
+
+    const a = el.getBoundingClientRect()
+    const b = root.getBoundingClientRect()
+    const top = a.top - b.top + root.scrollTop
+    root.scrollTo({ top, behavior })
+    return true
+  }
+
+  const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
+    input.setActiveMessage(message)
+
+    const msgs = input.visibleUserMessages()
+    const index = msgs.findIndex((m) => m.id === message.id)
+    if (index !== -1 && index < input.turnStart()) {
+      input.setTurnStart(index)
+      input.scheduleTurnBackfill()
+
+      requestAnimationFrame(() => {
+        const el = document.getElementById(input.anchor(message.id))
+        if (!el) {
+          requestAnimationFrame(() => {
+            const next = document.getElementById(input.anchor(message.id))
+            if (!next) return
+            scrollToElement(next, behavior)
+          })
+          return
+        }
+        scrollToElement(el, behavior)
+      })
+
+      updateHash(message.id)
+      return
+    }
+
+    const el = document.getElementById(input.anchor(message.id))
+    if (!el) {
+      updateHash(message.id)
+      requestAnimationFrame(() => {
+        const next = document.getElementById(input.anchor(message.id))
+        if (!next) return
+        if (!scrollToElement(next, behavior)) return
+      })
+      return
+    }
+    if (scrollToElement(el, behavior)) {
+      updateHash(message.id)
+      return
+    }
+
+    requestAnimationFrame(() => {
+      const next = document.getElementById(input.anchor(message.id))
+      if (!next) return
+      if (!scrollToElement(next, behavior)) return
+    })
+    updateHash(message.id)
+  }
+
+  const applyHash = (behavior: ScrollBehavior) => {
+    const hash = window.location.hash.slice(1)
+    if (!hash) {
+      input.autoScroll.forceScrollToBottom()
+      const el = input.scroller()
+      if (el) input.scheduleScrollState(el)
+      return
+    }
+
+    const messageId = messageIdFromHash(hash)
+    if (messageId) {
+      input.autoScroll.pause()
+      const msg = input.visibleUserMessages().find((m) => m.id === messageId)
+      if (msg) {
+        scrollToMessage(msg, behavior)
+        return
+      }
+      return
+    }
+
+    const target = document.getElementById(hash)
+    if (target) {
+      input.autoScroll.pause()
+      scrollToElement(target, behavior)
+      return
+    }
+
+    input.autoScroll.forceScrollToBottom()
+    const el = input.scroller()
+    if (el) input.scheduleScrollState(el)
+  }
+
+  createEffect(
+    on(input.sessionKey, (key) => {
+      if (!input.sessionID()) return
+      const messageID = input.consumePendingMessage(key)
+      if (!messageID) return
+      input.setPendingMessage(messageID)
+    }),
+  )
+
+  createEffect(() => {
+    if (!input.sessionID() || !input.messagesReady()) return
+    requestAnimationFrame(() => applyHash("auto"))
+  })
+
+  createEffect(() => {
+    if (!input.sessionID() || !input.messagesReady()) return
+
+    input.visibleUserMessages().length
+    input.turnStart()
+
+    const targetId = input.pendingMessage() ?? messageIdFromHash(window.location.hash)
+    if (!targetId) return
+    if (input.currentMessageId() === targetId) return
+
+    const msg = input.visibleUserMessages().find((m) => m.id === targetId)
+    if (!msg) return
+
+    if (input.pendingMessage() === targetId) input.setPendingMessage(undefined)
+    input.autoScroll.pause()
+    requestAnimationFrame(() => scrollToMessage(msg, "auto"))
+  })
+
+  createEffect(() => {
+    if (!input.sessionID() || !input.messagesReady()) return
+    const handler = () => requestAnimationFrame(() => applyHash("auto"))
+    window.addEventListener("hashchange", handler)
+    onCleanup(() => window.removeEventListener("hashchange", handler))
+  })
+
+  return {
+    clearMessageHash,
+    scrollToMessage,
+    applyHash,
+  }
+}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů