Jelajahi Sumber

chore: cleanup (#17197)

Adam 1 bulan lalu
induk
melakukan
dce7eceb28

+ 159 - 0
packages/app/src/components/dialog-custom-provider-form.ts

@@ -0,0 +1,159 @@
+const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
+const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
+
+type Translator = (key: string, vars?: Record<string, string | number | boolean>) => string
+
+export type ModelErr = {
+  id?: string
+  name?: string
+}
+
+export type HeaderErr = {
+  key?: string
+  value?: string
+}
+
+export type ModelRow = {
+  row: string
+  id: string
+  name: string
+  err: ModelErr
+}
+
+export type HeaderRow = {
+  row: string
+  key: string
+  value: string
+  err: HeaderErr
+}
+
+export type FormState = {
+  providerID: string
+  name: string
+  baseURL: string
+  apiKey: string
+  models: ModelRow[]
+  headers: HeaderRow[]
+  saving: boolean
+  err: {
+    providerID?: string
+    name?: string
+    baseURL?: string
+  }
+}
+
+type ValidateArgs = {
+  form: FormState
+  t: Translator
+  disabledProviders: string[]
+  existingProviderIDs: Set<string>
+}
+
+export function validateCustomProvider(input: ValidateArgs) {
+  const providerID = input.form.providerID.trim()
+  const name = input.form.name.trim()
+  const baseURL = input.form.baseURL.trim()
+  const apiKey = input.form.apiKey.trim()
+
+  const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
+  const key = apiKey && !env ? apiKey : undefined
+
+  const idError = !providerID
+    ? input.t("provider.custom.error.providerID.required")
+    : !PROVIDER_ID.test(providerID)
+      ? input.t("provider.custom.error.providerID.format")
+      : undefined
+
+  const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
+  const urlError = !baseURL
+    ? input.t("provider.custom.error.baseURL.required")
+    : !/^https?:\/\//.test(baseURL)
+      ? input.t("provider.custom.error.baseURL.format")
+      : undefined
+
+  const disabled = input.disabledProviders.includes(providerID)
+  const existsError = idError
+    ? undefined
+    : input.existingProviderIDs.has(providerID) && !disabled
+      ? input.t("provider.custom.error.providerID.exists")
+      : undefined
+
+  const seenModels = new Set<string>()
+  const models = input.form.models.map((m) => {
+    const id = m.id.trim()
+    const idError = !id
+      ? input.t("provider.custom.error.required")
+      : seenModels.has(id)
+        ? input.t("provider.custom.error.duplicate")
+        : (() => {
+            seenModels.add(id)
+            return undefined
+          })()
+    const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
+    return { id: idError, name: nameError }
+  })
+  const modelsValid = models.every((m) => !m.id && !m.name)
+  const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
+
+  const seenHeaders = new Set<string>()
+  const headers = input.form.headers.map((h) => {
+    const key = h.key.trim()
+    const value = h.value.trim()
+
+    if (!key && !value) return {}
+    const keyError = !key
+      ? input.t("provider.custom.error.required")
+      : seenHeaders.has(key.toLowerCase())
+        ? input.t("provider.custom.error.duplicate")
+        : (() => {
+            seenHeaders.add(key.toLowerCase())
+            return undefined
+          })()
+    const valueError = !value ? input.t("provider.custom.error.required") : undefined
+    return { key: keyError, value: valueError }
+  })
+  const headersValid = headers.every((h) => !h.key && !h.value)
+  const headerConfig = Object.fromEntries(
+    input.form.headers
+      .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
+      .filter((h) => !!h.key && !!h.value)
+      .map((h) => [h.key, h.value]),
+  )
+
+  const err = {
+    providerID: idError ?? existsError,
+    name: nameError,
+    baseURL: urlError,
+  }
+
+  const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
+  if (!ok) return { err, models, headers }
+
+  return {
+    err,
+    models,
+    headers,
+    result: {
+      providerID,
+      name,
+      key,
+      config: {
+        npm: OPENAI_COMPATIBLE,
+        name,
+        ...(env ? { env: [env] } : {}),
+        options: {
+          baseURL,
+          ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}),
+        },
+        models: modelConfig,
+      },
+    },
+  }
+}
+
+let row = 0
+
+const nextRow = () => `row-${row++}`
+
+export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} })
+export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} })

+ 82 - 0
packages/app/src/components/dialog-custom-provider.test.ts

@@ -0,0 +1,82 @@
+import { describe, expect, test } from "bun:test"
+import { validateCustomProvider } from "./dialog-custom-provider-form"
+
+const t = (key: string) => key
+
+describe("validateCustomProvider", () => {
+  test("builds trimmed config payload", () => {
+    const result = validateCustomProvider({
+      form: {
+        providerID: "custom-provider",
+        name: " Custom Provider ",
+        baseURL: "https://api.example.com ",
+        apiKey: " {env: CUSTOM_PROVIDER_KEY} ",
+        models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }],
+        headers: [
+          { row: "h0", key: " X-Test ", value: " enabled ", err: {} },
+          { row: "h1", key: "", value: "", err: {} },
+        ],
+        saving: false,
+        err: {},
+      },
+      t,
+      disabledProviders: [],
+      existingProviderIDs: new Set(),
+    })
+
+    expect(result.result).toEqual({
+      providerID: "custom-provider",
+      name: "Custom Provider",
+      key: undefined,
+      config: {
+        npm: "@ai-sdk/openai-compatible",
+        name: "Custom Provider",
+        env: ["CUSTOM_PROVIDER_KEY"],
+        options: {
+          baseURL: "https://api.example.com",
+          headers: {
+            "X-Test": "enabled",
+          },
+        },
+        models: {
+          "model-a": { name: "Model A" },
+        },
+      },
+    })
+  })
+
+  test("flags duplicate rows and allows reconnecting disabled providers", () => {
+    const result = validateCustomProvider({
+      form: {
+        providerID: "custom-provider",
+        name: "Provider",
+        baseURL: "https://api.example.com",
+        apiKey: "secret",
+        models: [
+          { row: "m0", id: "model-a", name: "Model A", err: {} },
+          { row: "m1", id: "model-a", name: "Model A 2", err: {} },
+        ],
+        headers: [
+          { row: "h0", key: "Authorization", value: "one", err: {} },
+          { row: "h1", key: "authorization", value: "two", err: {} },
+        ],
+        saving: false,
+        err: {},
+      },
+      t,
+      disabledProviders: ["custom-provider"],
+      existingProviderIDs: new Set(["custom-provider"]),
+    })
+
+    expect(result.result).toBeUndefined()
+    expect(result.err.providerID).toBeUndefined()
+    expect(result.models[1]).toEqual({
+      id: "provider.custom.error.duplicate",
+      name: undefined,
+    })
+    expect(result.headers[1]).toEqual({
+      key: "provider.custom.error.duplicate",
+      value: undefined,
+    })
+  })
+})

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

@@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { For } from "solid-js"
-import { createStore } from "solid-js/store"
+import { batch, For } from "solid-js"
+import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
+import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
 import { DialogSelectProvider } from "./dialog-select-provider"
 
-const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
-const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
-
-type Translator = ReturnType<typeof useLanguage>["t"]
-
-type ModelRow = {
-  id: string
-  name: string
-}
-
-type HeaderRow = {
-  key: string
-  value: string
-}
-
-type FormState = {
-  providerID: string
-  name: string
-  baseURL: string
-  apiKey: string
-  models: ModelRow[]
-  headers: HeaderRow[]
-  saving: boolean
-}
-
-type FormErrors = {
-  providerID: string | undefined
-  name: string | undefined
-  baseURL: string | undefined
-  models: Array<{ id?: string; name?: string }>
-  headers: Array<{ key?: string; value?: string }>
-}
-
-type ValidateArgs = {
-  form: FormState
-  t: Translator
-  disabledProviders: string[]
-  existingProviderIDs: Set<string>
-}
-
-function validateCustomProvider(input: ValidateArgs) {
-  const providerID = input.form.providerID.trim()
-  const name = input.form.name.trim()
-  const baseURL = input.form.baseURL.trim()
-  const apiKey = input.form.apiKey.trim()
-
-  const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
-  const key = apiKey && !env ? apiKey : undefined
-
-  const idError = !providerID
-    ? input.t("provider.custom.error.providerID.required")
-    : !PROVIDER_ID.test(providerID)
-      ? input.t("provider.custom.error.providerID.format")
-      : undefined
-
-  const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
-  const urlError = !baseURL
-    ? input.t("provider.custom.error.baseURL.required")
-    : !/^https?:\/\//.test(baseURL)
-      ? input.t("provider.custom.error.baseURL.format")
-      : undefined
-
-  const disabled = input.disabledProviders.includes(providerID)
-  const existsError = idError
-    ? undefined
-    : input.existingProviderIDs.has(providerID) && !disabled
-      ? input.t("provider.custom.error.providerID.exists")
-      : undefined
-
-  const seenModels = new Set<string>()
-  const modelErrors = input.form.models.map((m) => {
-    const id = m.id.trim()
-    const modelIdError = !id
-      ? input.t("provider.custom.error.required")
-      : seenModels.has(id)
-        ? input.t("provider.custom.error.duplicate")
-        : (() => {
-            seenModels.add(id)
-            return undefined
-          })()
-    const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
-    return { id: modelIdError, name: modelNameError }
-  })
-  const modelsValid = modelErrors.every((m) => !m.id && !m.name)
-  const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
-
-  const seenHeaders = new Set<string>()
-  const headerErrors = input.form.headers.map((h) => {
-    const key = h.key.trim()
-    const value = h.value.trim()
-
-    if (!key && !value) return {}
-    const keyError = !key
-      ? input.t("provider.custom.error.required")
-      : seenHeaders.has(key.toLowerCase())
-        ? input.t("provider.custom.error.duplicate")
-        : (() => {
-            seenHeaders.add(key.toLowerCase())
-            return undefined
-          })()
-    const valueError = !value ? input.t("provider.custom.error.required") : undefined
-    return { key: keyError, value: valueError }
-  })
-  const headersValid = headerErrors.every((h) => !h.key && !h.value)
-  const headers = Object.fromEntries(
-    input.form.headers
-      .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
-      .filter((h) => !!h.key && !!h.value)
-      .map((h) => [h.key, h.value]),
-  )
-
-  const errors: FormErrors = {
-    providerID: idError ?? existsError,
-    name: nameError,
-    baseURL: urlError,
-    models: modelErrors,
-    headers: headerErrors,
-  }
-
-  const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
-  if (!ok) return { errors }
-
-  const options = {
-    baseURL,
-    ...(Object.keys(headers).length ? { headers } : {}),
-  }
-
-  return {
-    errors,
-    result: {
-      providerID,
-      name,
-      key,
-      config: {
-        npm: OPENAI_COMPATIBLE,
-        name,
-        ...(env ? { env: [env] } : {}),
-        options,
-        models,
-      },
-    },
-  }
-}
-
 type Props = {
   back?: "providers" | "close"
 }
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
     name: "",
     baseURL: "",
     apiKey: "",
-    models: [{ id: "", name: "" }],
-    headers: [{ key: "", value: "" }],
+    models: [modelRow()],
+    headers: [headerRow()],
     saving: false,
-  })
-
-  const [errors, setErrors] = createStore<FormErrors>({
-    providerID: undefined,
-    name: undefined,
-    baseURL: undefined,
-    models: [{}],
-    headers: [{}],
+    err: {},
   })
 
   const goBack = () => {
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
   }
 
   const addModel = () => {
-    setForm("models", (v) => [...v, { id: "", name: "" }])
-    setErrors("models", (v) => [...v, {}])
+    setForm(
+      "models",
+      produce((rows) => {
+        rows.push(modelRow())
+      }),
+    )
   }
 
   const removeModel = (index: number) => {
     if (form.models.length <= 1) return
-    setForm("models", (v) => v.filter((_, i) => i !== index))
-    setErrors("models", (v) => v.filter((_, i) => i !== index))
+    setForm(
+      "models",
+      produce((rows) => {
+        rows.splice(index, 1)
+      }),
+    )
   }
 
   const addHeader = () => {
-    setForm("headers", (v) => [...v, { key: "", value: "" }])
-    setErrors("headers", (v) => [...v, {}])
+    setForm(
+      "headers",
+      produce((rows) => {
+        rows.push(headerRow())
+      }),
+    )
   }
 
   const removeHeader = (index: number) => {
     if (form.headers.length <= 1) return
-    setForm("headers", (v) => v.filter((_, i) => i !== index))
-    setErrors("headers", (v) => v.filter((_, i) => i !== index))
+    setForm(
+      "headers",
+      produce((rows) => {
+        rows.splice(index, 1)
+      }),
+    )
+  }
+
+  const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => {
+    setForm(key, value)
+    if (key === "apiKey") return
+    setForm("err", key, undefined)
+  }
+
+  const setModel = (index: number, key: "id" | "name", value: string) => {
+    batch(() => {
+      setForm("models", index, key, value)
+      setForm("models", index, "err", key, undefined)
+    })
+  }
+
+  const setHeader = (index: number, key: "key" | "value", value: string) => {
+    batch(() => {
+      setForm("headers", index, key, value)
+      setForm("headers", index, "err", key, undefined)
+    })
   }
 
   const validate = () => {
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
       disabledProviders: globalSync.data.config.disabled_providers ?? [],
       existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
     })
-    setErrors(output.errors)
+    batch(() => {
+      setForm("err", output.err)
+      output.models.forEach((err, index) => setForm("models", index, "err", err))
+      output.headers.forEach((err, index) => setForm("headers", index, "err", err))
+    })
     return output.result
   }
 
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
               placeholder={language.t("provider.custom.field.providerID.placeholder")}
               description={language.t("provider.custom.field.providerID.description")}
               value={form.providerID}
-              onChange={(v) => setForm("providerID", v)}
-              validationState={errors.providerID ? "invalid" : undefined}
-              error={errors.providerID}
+              onChange={(v) => setField("providerID", v)}
+              validationState={form.err.providerID ? "invalid" : undefined}
+              error={form.err.providerID}
             />
             <TextField
               label={language.t("provider.custom.field.name.label")}
               placeholder={language.t("provider.custom.field.name.placeholder")}
               value={form.name}
-              onChange={(v) => setForm("name", v)}
-              validationState={errors.name ? "invalid" : undefined}
-              error={errors.name}
+              onChange={(v) => setField("name", v)}
+              validationState={form.err.name ? "invalid" : undefined}
+              error={form.err.name}
             />
             <TextField
               label={language.t("provider.custom.field.baseURL.label")}
               placeholder={language.t("provider.custom.field.baseURL.placeholder")}
               value={form.baseURL}
-              onChange={(v) => setForm("baseURL", v)}
-              validationState={errors.baseURL ? "invalid" : undefined}
-              error={errors.baseURL}
+              onChange={(v) => setField("baseURL", v)}
+              validationState={form.err.baseURL ? "invalid" : undefined}
+              error={form.err.baseURL}
             />
             <TextField
               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={(v) => setForm("apiKey", v)}
+              onChange={(v) => setField("apiKey", v)}
             />
           </div>
 
@@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) {
             <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 gap-2 items-start" data-row={m.row}>
                   <div class="flex-1">
                     <TextField
                       label={language.t("provider.custom.models.id.label")}
                       hideLabel
                       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}
-                      error={errors.models[i()]?.id}
+                      onChange={(v) => setModel(i(), "id", v)}
+                      validationState={m.err.id ? "invalid" : undefined}
+                      error={m.err.id}
                     />
                   </div>
                   <div class="flex-1">
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
                       hideLabel
                       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}
-                      error={errors.models[i()]?.name}
+                      onChange={(v) => setModel(i(), "name", v)}
+                      validationState={m.err.name ? "invalid" : undefined}
+                      error={m.err.name}
                     />
                   </div>
                   <IconButton
@@ -382,16 +272,16 @@ export function DialogCustomProvider(props: Props) {
             <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 gap-2 items-start" data-row={h.row}>
                   <div class="flex-1">
                     <TextField
                       label={language.t("provider.custom.headers.key.label")}
                       hideLabel
                       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}
-                      error={errors.headers[i()]?.key}
+                      onChange={(v) => setHeader(i(), "key", v)}
+                      validationState={h.err.key ? "invalid" : undefined}
+                      error={h.err.key}
                     />
                   </div>
                   <div class="flex-1">
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
                       hideLabel
                       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}
-                      error={errors.headers[i()]?.value}
+                      onChange={(v) => setHeader(i(), "value", v)}
+                      validationState={h.err.value ? "invalid" : undefined}
+                      error={h.err.value}
                     />
                   </div>
                   <IconButton

+ 8 - 2
packages/app/src/components/dialog-select-file.tsx

@@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { getRelativeTime } from "@/utils/time"
 
@@ -133,9 +134,14 @@ function createFileEntries(props: {
   tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
   language: ReturnType<typeof useLanguage>
 }) {
+  const tabState = createSessionTabs({
+    tabs: props.tabs,
+    pathFromTab: props.file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab),
+  })
   const recent = createMemo(() => {
-    const all = props.tabs().all()
-    const active = props.tabs().active()
+    const all = tabState.openedTabs()
+    const active = tabState.activeFileTab()
     const order = active ? [active, ...all.filter((item) => item !== active)] : all
     const seen = new Set<string>()
     const category = props.language.t("palette.group.files")

+ 8 - 1
packages/app/src/components/prompt-input.tsx

@@ -37,6 +37,7 @@ import { usePermission } from "@/context/permission"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
 import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
 import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
 import {
@@ -154,6 +155,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     requestAnimationFrame(scrollCursorIntoView)
   }
 
+  const activeFileTab = createSessionTabs({
+    tabs,
+    pathFromTab: files.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
+  }).activeFileTab
+
   const commentInReview = (path: string) => {
     const sessionID = params.id
     if (!sessionID) return false
@@ -205,7 +212,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const recent = createMemo(() => {
     const all = tabs().all()
-    const active = tabs().active()
+    const active = activeFileTab()
     const order = active ? [active, ...all.filter((x) => x !== active)] : all
     const seen = new Set<string>()
     const paths: string[] = []

+ 9 - 1
packages/app/src/components/session-context-usage.tsx

@@ -3,11 +3,13 @@ import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
 import { Button } from "@opencode-ai/ui/button"
 
+import { useFile } from "@/context/file"
 import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { useLanguage } from "@/context/language"
 import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
 
 interface SessionContextUsageProps {
   variant?: "button" | "indicator"
@@ -27,11 +29,17 @@ function openSessionContext(args: {
 
 export function SessionContextUsage(props: SessionContextUsageProps) {
   const sync = useSync()
+  const file = useFile()
   const layout = useLayout()
   const language = useLanguage()
   const { params, tabs, view } = useSessionLayout()
 
   const variant = createMemo(() => props.variant ?? "button")
+  const tabState = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+  })
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
 
   const usd = createMemo(
@@ -51,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   const openContext = () => {
     if (!params.id) return
 
-    if (tabs().active() === "context") {
+    if (tabState.activeTab() === "context") {
       tabs().close("context")
       return
     }

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

@@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col"
 
 interface NewSessionViewProps {
   worktree: string
-  onWorktreeChange: (value: string) => void
 }
 
 export function NewSessionView(props: NewSessionViewProps) {

+ 8 - 7
packages/app/src/context/highlights.tsx

@@ -1,4 +1,4 @@
-import { createEffect, createSignal, onCleanup } from "solid-js"
+import { createEffect, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
     const settings = useSettings()
     const [store, setStore, _, ready] = persisted("highlights.v1", createStore<Store>({ version: undefined }))
 
-    const [from, setFrom] = createSignal<string | undefined>(undefined)
-    const [to, setTo] = createSignal<string | undefined>(undefined)
+    const [range, setRange] = createStore({
+      from: undefined as string | undefined,
+      to: undefined as string | undefined,
+    })
     const state = { started: false }
     let timer: ReturnType<typeof setTimeout> | undefined
 
@@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
 
       if (previous === platform.version) return
 
-      setFrom(previous)
-      setTo(platform.version)
+      setRange({ from: previous, to: platform.version })
       start(previous)
     })
 
     return {
       ready,
-      from,
-      to,
+      from: () => range.from,
+      to: () => range.to,
       get last() {
         return store.version
       },

+ 51 - 4
packages/app/src/context/layout.tsx

@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
             },
           },
           review: {
-            open: createMemo(() => s().reviewOpen),
+            open: createMemo(() => s().reviewOpen ?? []),
             setOpen(open: string[]) {
               const session = key()
+              const next = Array.from(new Set(open))
               const current = store.sessionView[session]
               if (!current) {
                 setStore("sessionView", session, {
                   scroll: {},
-                  reviewOpen: open,
+                  reviewOpen: next,
                 })
                 return
               }
 
-              if (same(current.reviewOpen, open)) return
-              setStore("sessionView", session, "reviewOpen", open)
+              if (same(current.reviewOpen, next)) return
+              setStore("sessionView", session, "reviewOpen", next)
+            },
+            openPath(path: string) {
+              const session = key()
+              const current = store.sessionView[session]
+              if (!current) {
+                setStore("sessionView", session, {
+                  scroll: {},
+                  reviewOpen: [path],
+                })
+                return
+              }
+
+              if (!current.reviewOpen) {
+                setStore("sessionView", session, "reviewOpen", [path])
+                return
+              }
+
+              if (current.reviewOpen.includes(path)) return
+              setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
+            },
+            closePath(path: string) {
+              const session = key()
+              const current = store.sessionView[session]?.reviewOpen
+              if (!current) return
+
+              const index = current.indexOf(path)
+              if (index === -1) return
+              setStore(
+                "sessionView",
+                session,
+                "reviewOpen",
+                produce((draft) => {
+                  if (!draft) return
+                  draft.splice(index, 1)
+                }),
+              )
+            },
+            togglePath(path: string) {
+              const session = key()
+              const current = store.sessionView[session]?.reviewOpen
+              if (!current || !current.includes(path)) {
+                this.openPath(path)
+                return
+              }
+
+              this.closePath(path)
             },
           },
         }

+ 18 - 16
packages/app/src/hooks/use-providers.ts

@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
 export function useProviders() {
   const globalSync = useGlobalSync()
   const params = useParams()
-  const currentDirectory = createMemo(() => decode64(params.dir) ?? "")
-  const providers = createMemo(() => {
-    if (currentDirectory()) {
-      const [projectStore] = globalSync.child(currentDirectory())
+  const dir = createMemo(() => decode64(params.dir) ?? "")
+  const providers = () => {
+    if (dir()) {
+      const [projectStore] = globalSync.child(dir())
       return projectStore.provider
     }
     return globalSync.data.provider
-  })
-  const connectedIDs = createMemo(() => new Set(providers().connected))
-  const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
-  const paid = createMemo(() =>
-    connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
-  )
-  const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
+  }
   return {
-    all: createMemo(() => providers().all),
-    default: createMemo(() => providers().default),
-    popular,
-    connected,
-    paid,
+    all: () => providers().all,
+    default: () => providers().default,
+    popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
+    connected: () => {
+      const connected = new Set(providers().connected)
+      return providers().all.filter((p) => connected.has(p.id))
+    },
+    paid: () => {
+      const connected = new Set(providers().connected)
+      return providers().all.filter(
+        (p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)),
+      )
+    },
   }
 }

+ 53 - 73
packages/app/src/pages/session.tsx

@@ -19,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange
 import { createStore } from "solid-js/store"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Select } from "@opencode-ai/ui/select"
+import { Tabs } from "@opencode-ai/ui/tabs"
 import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
 import { Button } from "@opencode-ai/ui/button"
@@ -36,12 +37,11 @@ import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { useTerminal } from "@/context/terminal"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
+import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
-import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
@@ -373,18 +373,22 @@ export default function Page() {
     if (!view().reviewPanel.opened()) view().reviewPanel.open()
   }
 
-  createEffect(() => {
-    const active = tabs().active()
-    if (!active) return
-
-    const path = file.pathFromTab(active)
-    if (path) file.load(path)
-  })
-
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
   const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
   const hasReview = createMemo(() => reviewCount() > 0)
+  const reviewTab = createMemo(() => isDesktop())
+  const tabState = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab,
+    review: reviewTab,
+    hasReview,
+  })
+  const contextOpen = tabState.contextOpen
+  const openedTabs = tabState.openedTabs
+  const activeTab = tabState.activeTab
+  const activeFileTab = tabState.activeFileTab
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
   const messagesReady = createMemo(() => {
@@ -421,6 +425,14 @@ export default function Page() {
   )
   const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
 
+  createEffect(() => {
+    const tab = activeFileTab()
+    if (!tab) return
+
+    const path = file.pathFromTab(tab)
+    if (path) file.load(path)
+  })
+
   createEffect(
     on(
       () => lastUserMessage()?.id,
@@ -806,15 +818,7 @@ export default function Page() {
     }
   }
 
-  const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
-  const openedTabs = createMemo(() =>
-    tabs()
-      .all()
-      .filter((tab) => tab !== "context" && tab !== "review"),
-  )
-
   const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
-  const reviewTab = createMemo(() => isDesktop())
 
   const fileTreeTab = () => layout.fileTree.tab()
   const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
@@ -850,6 +854,7 @@ export default function Page() {
     navigateMessageByOffset,
     setActiveMessage,
     focusInput,
+    review: reviewTab,
   })
 
   const openReviewFile = createOpenReviewFile({
@@ -964,11 +969,10 @@ export default function Page() {
 
   createEffect(
     on(
-      () => tabs().active(),
+      activeFileTab,
       (active) => {
         if (!active) return
         if (fileTreeTab() !== "changes") return
-        if (!file.pathFromTab(active)) return
         showAllFiles()
       },
       { defer: true },
@@ -1011,8 +1015,7 @@ export default function Page() {
 
   const focusReviewDiff = (path: string) => {
     openReviewPanel()
-    const current = view().review.open() ?? []
-    if (!current.includes(path)) view().review.setOpen([...current, path])
+    view().review.openPath(path)
     setTree({ activeDiff: path, pendingDiff: path })
   }
 
@@ -1057,29 +1060,6 @@ export default function Page() {
     requestAnimationFrame(() => attempt(0))
   })
 
-  const activeTab = createMemo(() => {
-    const active = tabs().active()
-    if (active === "context") return "context"
-    if (active === "review" && reviewTab()) return "review"
-    if (active && file.pathFromTab(active)) return normalizeTab(active)
-
-    const first = openedTabs()[0]
-    if (first) return first
-    if (contextOpen()) return "context"
-    if (reviewTab() && hasReview()) return "review"
-    return "empty"
-  })
-
-  createEffect(() => {
-    if (!layout.ready()) return
-    if (tabs().active()) return
-    if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return
-
-    const next = activeTab()
-    if (next === "empty") return
-    tabs().setActive(next)
-  })
-
   createEffect(() => {
     const id = params.id
     if (!id) return
@@ -1146,9 +1126,9 @@ export default function Page() {
       () => {
         void file.tree.list("")
 
-        const active = tabs().active()
-        if (!active) return
-        const path = file.pathFromTab(active)
+        const tab = activeFileTab()
+        if (!tab) return
+        const path = file.pathFromTab(tab)
         if (!path) return
         void file.load(path, { force: true })
       },
@@ -1400,14 +1380,30 @@ export default function Page() {
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
       <SessionHeader />
       <div class="flex-1 min-h-0 flex flex-col md:flex-row">
-        <SessionMobileTabs
-          open={!isDesktop() && !!params.id}
-          mobileTab={store.mobileTab}
-          hasReview={hasReview()}
-          reviewCount={reviewCount()}
-          onSession={() => setStore("mobileTab", "session")}
-          onChanges={() => setStore("mobileTab", "changes")}
-        />
+        <Show when={!isDesktop() && !!params.id}>
+          <Tabs value={store.mobileTab} class="h-auto">
+            <Tabs.List>
+              <Tabs.Trigger
+                value="session"
+                class="!w-1/2 !max-w-none"
+                classes={{ button: "w-full" }}
+                onClick={() => setStore("mobileTab", "session")}
+              >
+                {language.t("session.tab.session")}
+              </Tabs.Trigger>
+              <Tabs.Trigger
+                value="changes"
+                class="!w-1/2 !max-w-none !border-r-0"
+                classes={{ button: "w-full" }}
+                onClick={() => setStore("mobileTab", "changes")}
+              >
+                {hasReview()
+                  ? language.t("session.review.filesChanged", { count: reviewCount() })
+                  : language.t("session.review.change.other")}
+              </Tabs.Trigger>
+            </Tabs.List>
+          </Tabs>
+        </Show>
 
         {/* Session panel */}
         <div
@@ -1467,23 +1463,7 @@ export default function Page() {
                 </Show>
               </Match>
               <Match when={true}>
-                <NewSessionView
-                  worktree={newSessionWorktree()}
-                  onWorktreeChange={(value) => {
-                    if (value === "create") {
-                      setStore("newSessionWorktree", value)
-                      return
-                    }
-
-                    setStore("newSessionWorktree", "main")
-
-                    const target = value === "main" ? sync.project?.worktree : value
-                    if (!target) return
-                    if (target === sdk.directory) return
-                    layout.projects.open(target)
-                    navigate(`/${base64Encode(target)}/session`)
-                  }}
-                />
+                <NewSessionView worktree={newSessionWorktree()} />
               </Match>
             </Switch>
           </div>

+ 17 - 14
packages/app/src/pages/session/composer/session-composer-region.tsx

@@ -1,4 +1,5 @@
-import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
+import { Show, createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { PromptInput } from "@/components/prompt-input"
 import { useLanguage } from "@/context/language"
@@ -50,7 +51,11 @@ export function SessionComposerRegion(props: {
     setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
   })
 
-  const [ready, setReady] = createSignal(false)
+  const [store, setStore] = createStore({
+    ready: false,
+    height: 320,
+    body: undefined as HTMLDivElement | undefined,
+  })
   let timer: number | undefined
   let frame: number | undefined
 
@@ -67,17 +72,17 @@ export function SessionComposerRegion(props: {
 
   createEffect(() => {
     sessionKey()
-    const active = props.ready
+    const ready = props.ready
     const delay = 140
 
     clear()
-    setReady(false)
-    if (!active) return
+    setStore("ready", false)
+    if (!ready) return
 
     frame = requestAnimationFrame(() => {
       frame = undefined
       timer = window.setTimeout(() => {
-        setReady(true)
+        setStore("ready", true)
         timer = undefined
       }, delay)
     })
@@ -85,21 +90,19 @@ export function SessionComposerRegion(props: {
 
   onCleanup(clear)
 
-  const open = createMemo(() => ready() && props.state.dock() && !props.state.closing())
+  const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing())
   const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
   const value = createMemo(() => Math.max(0, Math.min(1, progress())))
-  const [height, setHeight] = createSignal(320)
-  const dock = createMemo(() => (ready() && props.state.dock()) || value() > 0.001)
+  const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001)
   const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined))
   const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
-  const full = createMemo(() => Math.max(78, height()))
-  const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
+  const full = createMemo(() => Math.max(78, store.height))
 
   createEffect(() => {
-    const el = contentRef()
+    const el = store.body
     if (!el) return
     const update = () => {
-      setHeight(el.getBoundingClientRect().height)
+      setStore("height", el.getBoundingClientRect().height)
     }
     update()
     const observer = new ResizeObserver(update)
@@ -174,7 +177,7 @@ export function SessionComposerRegion(props: {
                   "max-height": `${full() * value()}px`,
                 }}
               >
-                <div ref={setContentRef}>
+                <div ref={(el) => setStore("body", el)}>
                   <SessionTodoDock
                     todos={props.state.todos()}
                     title={language.t("session.todo.title")}

+ 26 - 20
packages/app/src/pages/session/composer/session-todo-dock.tsx

@@ -6,7 +6,8 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { useSpring } from "@opencode-ai/ui/motion-spring"
 import { TextReveal } from "@opencode-ai/ui/text-reveal"
 import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
-import { Index, createEffect, createMemo, createSignal, on, onCleanup } from "solid-js"
+import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
 
 function dot(status: Todo["status"]) {
   if (status !== "in_progress") return undefined
@@ -40,8 +41,12 @@ export function SessionTodoDock(props: {
   expandLabel: string
   dockProgress: number
 }) {
-  const [collapsed, setCollapsed] = createSignal(false)
-  const toggle = () => setCollapsed((value) => !value)
+  const [store, setStore] = createStore({
+    collapsed: false,
+    height: 320,
+  })
+
+  const toggle = () => setStore("collapsed", (value) => !value)
 
   const total = createMemo(() => props.todos.length)
   const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
@@ -56,22 +61,21 @@ export function SessionTodoDock(props: {
   )
 
   const preview = createMemo(() => active()?.content ?? "")
-  const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
+  const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
   const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
   const shut = createMemo(() => 1 - dock())
   const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
   const hide = createMemo(() => Math.max(value(), shut()))
   const off = createMemo(() => hide() > 0.98)
   const turn = createMemo(() => Math.max(0, Math.min(1, value())))
-  const [height, setHeight] = createSignal(320)
-  const full = createMemo(() => Math.max(78, height()))
+  const full = createMemo(() => Math.max(78, store.height))
   let contentRef: HTMLDivElement | undefined
 
   createEffect(() => {
     const el = contentRef
     if (!el) return
     const update = () => {
-      setHeight(el.getBoundingClientRect().height)
+      setStore("height", el.getBoundingClientRect().height)
     }
     update()
     const observer = new ResizeObserver(update)
@@ -127,7 +131,7 @@ export function SessionTodoDock(props: {
           >
             <TextReveal
               class="text-14-regular text-text-base cursor-default"
-              text={collapsed() ? preview() : undefined}
+              text={store.collapsed ? preview() : undefined}
               duration={600}
               travel={25}
               edge={17}
@@ -140,7 +144,7 @@ export function SessionTodoDock(props: {
           <div class="ml-auto">
             <IconButton
               data-action="session-todo-toggle-button"
-              data-collapsed={collapsed() ? "true" : "false"}
+              data-collapsed={store.collapsed ? "true" : "false"}
               icon="chevron-down"
               size="normal"
               variant="ghost"
@@ -153,14 +157,14 @@ export function SessionTodoDock(props: {
                 event.stopPropagation()
                 toggle()
               }}
-              aria-label={collapsed() ? props.expandLabel : props.collapseLabel}
+              aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
             />
           </div>
         </div>
 
         <div
           data-slot="session-todo-list"
-          aria-hidden={collapsed() || off()}
+          aria-hidden={store.collapsed || off()}
           classList={{
             "pointer-events-none": hide() > 0.1,
           }}
@@ -169,7 +173,7 @@ export function SessionTodoDock(props: {
             opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`,
           }}
         >
-          <TodoList todos={props.todos} open={!collapsed()} />
+          <TodoList todos={props.todos} open={!store.collapsed} />
         </div>
       </div>
     </DockTray>
@@ -177,8 +181,10 @@ export function SessionTodoDock(props: {
 }
 
 function TodoList(props: { todos: Todo[]; open: boolean }) {
-  const [stuck, setStuck] = createSignal(false)
-  const [scrolling, setScrolling] = createSignal(false)
+  const [store, setStore] = createStore({
+    stuck: false,
+    scrolling: false,
+  })
   let scrollRef!: HTMLDivElement
   let timer: number | undefined
 
@@ -186,7 +192,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
 
   const ensure = () => {
     if (!props.open) return
-    if (scrolling()) return
+    if (store.scrolling) return
     if (!scrollRef || scrollRef.offsetParent === null) return
 
     const el = scrollRef.querySelector("[data-in-progress]")
@@ -207,7 +213,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
       scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade)
     }
 
-    setStuck(scrollRef.scrollTop > 0)
+    setStore("stuck", scrollRef.scrollTop > 0)
   }
 
   createEffect(
@@ -229,11 +235,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
         ref={scrollRef}
         style={{ "overflow-anchor": "none" }}
         onScroll={(e) => {
-          setStuck(e.currentTarget.scrollTop > 0)
-          setScrolling(true)
+          setStore("stuck", e.currentTarget.scrollTop > 0)
+          setStore("scrolling", true)
           if (timer) window.clearTimeout(timer)
           timer = window.setTimeout(() => {
-            setScrolling(false)
+            setStore("scrolling", false)
             if (inProgress() < 0) return
             requestAnimationFrame(ensure)
           }, 250)
@@ -278,7 +284,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) {
         class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150"
         style={{
           background: "linear-gradient(to bottom, var(--background-base), transparent)",
-          opacity: stuck() ? 1 : 0,
+          opacity: store.stuck ? 1 : 0,
         }}
       />
     </div>

+ 9 - 3
packages/app/src/pages/session/file-tabs.tsx

@@ -17,6 +17,7 @@ import { useLanguage } from "@/context/language"
 import { usePrompt } from "@/context/prompt"
 import { getSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
+import { createSessionTabs } from "@/pages/session/helpers"
 
 function FileCommentMenu(props: {
   moreLabel: string
@@ -58,6 +59,11 @@ export function FileTabContent(props: { tab: string }) {
   const prompt = usePrompt()
   const fileComponent = useFileComponent()
   const { sessionKey, tabs, view } = useSessionLayout()
+  const activeFileTab = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
+  }).activeFileTab
 
   let scroll: HTMLDivElement | undefined
   let scrollFrame: number | undefined
@@ -228,7 +234,7 @@ export function FileTabContent(props: { tab: string }) {
     if (typeof window === "undefined") return
 
     const onKeyDown = (event: KeyboardEvent) => {
-      if (tabs().active() !== props.tab) return
+      if (activeFileTab() !== props.tab) return
       if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return
       if (event.key.toLowerCase() !== "f") return
 
@@ -256,7 +262,7 @@ export function FileTabContent(props: { tab: string }) {
     const p = path()
     if (!focus || !p) return
     if (focus.file !== p) return
-    if (tabs().active() !== props.tab) return
+    if (activeFileTab() !== props.tab) return
 
     const target = fileComments().find((comment) => comment.id === focus.id)
     if (!target) return
@@ -376,7 +382,7 @@ export function FileTabContent(props: { tab: string }) {
   createEffect(() => {
     const loaded = !!state()?.loaded
     const ready = file.ready()
-    const active = tabs().active() === props.tab
+    const active = activeFileTab() === props.tab
     const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
     prev = { loaded, ready, active }
     if (!restore) return

+ 72 - 1
packages/app/src/pages/session/helpers.test.ts

@@ -1,5 +1,13 @@
 import { describe, expect, test } from "bun:test"
-import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers"
+import { createMemo, createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import {
+  createOpenReviewFile,
+  createOpenSessionFileTab,
+  createSessionTabs,
+  focusTerminalById,
+  getTabReorderIndex,
+} from "./helpers"
 
 describe("createOpenReviewFile", () => {
   test("opens and loads selected review file", () => {
@@ -87,3 +95,66 @@ describe("getTabReorderIndex", () => {
     expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined()
   })
 })
+
+describe("createSessionTabs", () => {
+  test("normalizes the effective file tab", () => {
+    createRoot((dispose) => {
+      const [state] = createStore({
+        active: undefined as string | undefined,
+        all: ["file://src/a.ts", "context"],
+      })
+      const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
+      const result = createSessionTabs({
+        tabs,
+        pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined),
+        normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab),
+      })
+
+      expect(result.activeTab()).toBe("norm:src/a.ts")
+      expect(result.activeFileTab()).toBe("norm:src/a.ts")
+      expect(result.closableTab()).toBe("norm:src/a.ts")
+      dispose()
+    })
+  })
+
+  test("prefers context and review fallbacks when no file tab is active", () => {
+    createRoot((dispose) => {
+      const [state] = createStore({
+        active: undefined as string | undefined,
+        all: ["context"],
+      })
+      const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
+      const result = createSessionTabs({
+        tabs,
+        pathFromTab: () => undefined,
+        normalizeTab: (tab) => tab,
+        review: () => true,
+        hasReview: () => true,
+      })
+
+      expect(result.activeTab()).toBe("context")
+      expect(result.closableTab()).toBe("context")
+      dispose()
+    })
+
+    createRoot((dispose) => {
+      const [state] = createStore({
+        active: undefined as string | undefined,
+        all: [],
+      })
+      const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all }))
+      const result = createSessionTabs({
+        tabs,
+        pathFromTab: () => undefined,
+        normalizeTab: (tab) => tab,
+        review: () => true,
+        hasReview: () => true,
+      })
+
+      expect(result.activeTab()).toBe("review")
+      expect(result.activeFileTab()).toBeUndefined()
+      expect(result.closableTab()).toBeUndefined()
+      dispose()
+    })
+  })
+})

+ 73 - 1
packages/app/src/pages/session/helpers.ts

@@ -1,5 +1,77 @@
-import { batch, onCleanup, onMount } from "solid-js"
+import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
 import { createStore } from "solid-js/store"
+import { same } from "@/utils/same"
+
+const emptyTabs: string[] = []
+
+type Tabs = {
+  active: Accessor<string | undefined>
+  all: Accessor<string[]>
+}
+
+type TabsInput = {
+  tabs: Accessor<Tabs>
+  pathFromTab: (tab: string) => string | undefined
+  normalizeTab: (tab: string) => string
+  review?: Accessor<boolean>
+  hasReview?: Accessor<boolean>
+}
+
+export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}`
+
+export const createSessionTabs = (input: TabsInput) => {
+  const review = input.review ?? (() => false)
+  const hasReview = input.hasReview ?? (() => false)
+  const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context"))
+  const openedTabs = createMemo(
+    () => {
+      const seen = new Set<string>()
+      return input
+        .tabs()
+        .all()
+        .flatMap((tab) => {
+          if (tab === "context" || tab === "review") return []
+          const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab
+          if (seen.has(value)) return []
+          seen.add(value)
+          return [value]
+        })
+    },
+    emptyTabs,
+    { equals: same },
+  )
+  const activeTab = createMemo(() => {
+    const active = input.tabs().active()
+    if (active === "context") return active
+    if (active === "review" && review()) return active
+    if (active && input.pathFromTab(active)) return input.normalizeTab(active)
+
+    const first = openedTabs()[0]
+    if (first) return first
+    if (contextOpen()) return "context"
+    if (review() && hasReview()) return "review"
+    return "empty"
+  })
+  const activeFileTab = createMemo(() => {
+    const active = activeTab()
+    if (!openedTabs().includes(active)) return
+    return active
+  })
+  const closableTab = createMemo(() => {
+    const active = activeTab()
+    if (active === "context") return active
+    if (!openedTabs().includes(active)) return
+    return active
+  })
+
+  return {
+    contextOpen,
+    openedTabs,
+    activeTab,
+    activeFileTab,
+    closableTab,
+  }
+}
 
 export const focusTerminalById = (id: string) => {
   const wrapper = document.getElementById(`terminal-wrapper-${id}`)

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

@@ -37,14 +37,6 @@ export interface SessionReviewTabProps {
   }
 }
 
-export function StickyAddButton(props: { children: JSX.Element }) {
-  return (
-    <div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
-      {props.children}
-    </div>
-  )
-}
-
 export function SessionReviewTab(props: SessionReviewTabProps) {
   let scroll: HTMLDivElement | undefined
   let restoreFrame: number | undefined

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

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

+ 15 - 30
packages/app/src/pages/session/session-side-panel.tsx

@@ -22,8 +22,7 @@ import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
-import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
-import { StickyAddButton } from "@/pages/session/review-tab"
+import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 import { setSessionHandoff } from "@/pages/session/handoff"
 import { useSessionLayout } from "@/pages/session/session-layout"
 
@@ -132,31 +131,17 @@ export function SessionSidePanel(props: {
     setActive: tabs().setActive,
   })
 
-  const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
-  const openedTabs = createMemo(() =>
-    tabs()
-      .all()
-      .filter((tab) => tab !== "context" && tab !== "review"),
-  )
-
-  const activeTab = createMemo(() => {
-    const active = tabs().active()
-    if (active === "context") return "context"
-    if (active === "review" && reviewTab()) return "review"
-    if (active && file.pathFromTab(active)) return normalizeTab(active)
-
-    const first = openedTabs()[0]
-    if (first) return first
-    if (contextOpen()) return "context"
-    if (reviewTab() && hasReview()) return "review"
-    return "empty"
-  })
-
-  const activeFileTab = createMemo(() => {
-    const active = activeTab()
-    if (!openedTabs().includes(active)) return
-    return active
+  const tabState = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab,
+    review: reviewTab,
+    hasReview,
   })
+  const contextOpen = tabState.contextOpen
+  const openedTabs = tabState.openedTabs
+  const activeTab = tabState.activeTab
+  const activeFileTab = tabState.activeFileTab
 
   const fileTreeTab = () => layout.fileTree.tab()
 
@@ -297,7 +282,7 @@ export function SessionSidePanel(props: {
                       <SortableProvider ids={openedTabs()}>
                         <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
                       </SortableProvider>
-                      <StickyAddButton>
+                      <div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
                         <TooltipKeybind
                           title={language.t("command.file.open")}
                           keybind={command.keybind("file.open")}
@@ -314,7 +299,7 @@ export function SessionSidePanel(props: {
                             aria-label={language.t("command.file.open")}
                           />
                         </TooltipKeybind>
-                      </StickyAddButton>
+                      </div>
                     </Tabs.List>
                   </div>
 
@@ -354,10 +339,10 @@ export function SessionSidePanel(props: {
                 <DragOverlay>
                   <Show when={store.activeDraggable} keyed>
                     {(tab) => {
-                      const path = createMemo(() => file.pathFromTab(tab))
+                      const path = file.pathFromTab(tab)
                       return (
                         <div data-component="tabs-drag-preview">
-                          <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+                          <Show when={path}>{(p) => <FileVisual active path={p()} />}</Show>
                         </div>
                       )
                     }}

+ 11 - 18
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,4 +1,4 @@
-import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
+import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -13,7 +13,7 @@ import { Terminal } from "@/components/terminal"
 import { useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
-import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useTerminal } from "@/context/terminal"
 import { terminalTabLabel } from "@/pages/session/terminal-label"
 import { createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
@@ -41,7 +41,7 @@ export function TerminalPanel() {
   const max = () => store.view * 0.6
   const pane = () => Math.min(height(), max())
 
-  createEffect(() => {
+  onMount(() => {
     if (typeof window === "undefined") return
 
     const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
@@ -144,9 +144,8 @@ export function TerminalPanel() {
     return getTerminalHandoff(dir) ?? []
   })
 
-  const all = createMemo(() => terminal.all())
+  const all = terminal.all
   const ids = createMemo(() => all().map((pty) => pty.id))
-  const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }])))
 
   const handleTerminalDragStart = (event: unknown) => {
     const id = getDraggableId(event)
@@ -159,8 +158,8 @@ export function TerminalPanel() {
     if (!draggable || !droppable) return
 
     const terminals = terminal.all()
-    const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
-    const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
+    const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
+    const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
     if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
       terminal.move(draggable.id.toString(), toIndex)
     }
@@ -253,13 +252,7 @@ export function TerminalPanel() {
               >
                 <Tabs.List class="h-10 border-b border-border-weaker-base">
                   <SortableProvider ids={ids()}>
-                    <For each={ids()}>
-                      {(id) => (
-                        <Show when={byId().get(id)}>
-                          {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
-                        </Show>
-                      )}
-                    </For>
+                    <For each={all()}>{(pty) => <SortableTerminalTab terminal={pty} onClose={close} />}</For>
                   </SortableProvider>
                   <div class="h-full flex items-center justify-center">
                     <TooltipKeybind
@@ -281,7 +274,7 @@ export function TerminalPanel() {
               <div class="flex-1 min-h-0 relative">
                 <Show when={terminal.active()} keyed>
                   {(id) => (
-                    <Show when={byId().get(id)}>
+                    <Show when={all().find((pty) => pty.id === id)}>
                       {(pty) => (
                         <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
                           <Terminal
@@ -299,9 +292,9 @@ export function TerminalPanel() {
               </div>
             </div>
             <DragOverlay>
-              <Show when={store.activeDraggable}>
-                {(draggedId) => (
-                  <Show when={byId().get(draggedId())}>
+              <Show when={store.activeDraggable} keyed>
+                {(id) => (
+                  <Show when={all().find((pty) => pty.id === id)}>
                     {(t) => (
                       <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                         {terminalTabLabel({

+ 374 - 382
packages/app/src/pages/session/use-session-commands.tsx

@@ -1,4 +1,3 @@
-import { createMemo } from "solid-js"
 import { useNavigate } from "@solidjs/router"
 import { useCommand, type CommandOption } from "@/context/command"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -18,6 +17,7 @@ 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 { createSessionTabs } from "@/pages/session/helpers"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSessionLayout } from "@/pages/session/session-layout"
@@ -26,6 +26,7 @@ export type SessionCommandContext = {
   navigateMessageByOffset: (offset: number) => void
   setActiveMessage: (message: UserMessage | undefined) => void
   focusInput: () => void
+  review?: () => boolean
 }
 
 const withCategory = (category: string) => {
@@ -50,17 +51,43 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const navigate = useNavigate()
   const { params, tabs, view } = useSessionLayout()
 
-  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const info = () => {
+    const id = params.id
+    if (!id) return
+    return sync.session.get(id)
+  }
+  const hasReview = () => {
+    const id = params.id
+    if (!id) return false
+    return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0
+  }
+  const normalizeTab = (tab: string) => {
+    if (!tab.startsWith("file://")) return tab
+    return file.tab(tab)
+  }
+  const tabState = createSessionTabs({
+    tabs,
+    pathFromTab: file.pathFromTab,
+    normalizeTab,
+    review: actions.review,
+    hasReview,
+  })
+  const activeFileTab = tabState.activeFileTab
+  const closableTab = tabState.closableTab
 
   const idle = { type: "idle" as const }
-  const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
-  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
-  const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
-  const visibleUserMessages = createMemo(() => {
+  const status = () => sync.data.session_status[params.id ?? ""] ?? idle
+  const messages = () => {
+    const id = params.id
+    if (!id) return []
+    return sync.data.message[id] ?? []
+  }
+  const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[]
+  const visibleUserMessages = () => {
     const revert = info()?.revert?.messageID
     if (!revert) return userMessages()
     return userMessages().filter((m) => m.id < revert)
-  })
+  }
 
   const showAllFiles = () => {
     if (layout.fileTree.tab() !== "changes") return
@@ -79,9 +106,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   }
 
   const canAddSelectionContext = () => {
-    const active = tabs().active()
-    if (!active) return false
-    const path = file.pathFromTab(active)
+    const tab = activeFileTab()
+    if (!tab) return false
+    const path = file.pathFromTab(tab)
     if (!path) return false
     return file.selectedLines(path) != null
   }
@@ -100,404 +127,369 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
   const agentCommand = withCategory(language.t("command.category.agent"))
   const permissionsCommand = withCategory(language.t("command.category.permissions"))
 
-  const sessionCommands = createMemo(() => [
-    sessionCommand({
-      id: "session.new",
-      title: language.t("command.session.new"),
-      keybind: "mod+shift+s",
-      slash: "new",
-      onSelect: () => navigate(`/${params.dir}/session`),
-    }),
-  ])
-
-  const fileCommands = createMemo(() => [
-    fileCommand({
-      id: "file.open",
-      title: language.t("command.file.open"),
-      description: language.t("palette.search.placeholder"),
-      keybind: "mod+p",
-      slash: "open",
-      onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
-    }),
-    fileCommand({
-      id: "tab.close",
-      title: language.t("command.tab.close"),
-      keybind: "mod+w",
-      disabled: !tabs().active(),
-      onSelect: () => {
-        const active = tabs().active()
-        if (!active) return
-        tabs().close(active)
-      },
-    }),
-  ])
-
-  const contextCommands = createMemo(() => [
-    contextCommand({
-      id: "context.addSelection",
-      title: language.t("command.context.addSelection"),
-      description: language.t("command.context.addSelection.description"),
-      keybind: "mod+shift+l",
-      disabled: !canAddSelectionContext(),
-      onSelect: () => {
-        const active = tabs().active()
-        if (!active) return
-        const path = file.pathFromTab(active)
-        if (!path) return
-
-        const range = file.selectedLines(path) as SelectedLineRange | null | undefined
-        if (!range) {
-          showToast({
-            title: language.t("toast.context.noLineSelection.title"),
-            description: language.t("toast.context.noLineSelection.description"),
-          })
-          return
-        }
-
-        addSelectionToContext(path, selectionFromLines(range))
-      },
-    }),
-  ])
-
-  const viewCommands = createMemo(() => [
-    viewCommand({
-      id: "terminal.toggle",
-      title: language.t("command.terminal.toggle"),
-      keybind: "ctrl+`",
-      slash: "terminal",
-      onSelect: () => view().terminal.toggle(),
-    }),
-    viewCommand({
-      id: "review.toggle",
-      title: language.t("command.review.toggle"),
-      keybind: "mod+shift+r",
-      onSelect: () => view().reviewPanel.toggle(),
-    }),
-    viewCommand({
-      id: "fileTree.toggle",
-      title: language.t("command.fileTree.toggle"),
-      keybind: "mod+\\",
-      onSelect: () => layout.fileTree.toggle(),
-    }),
-    viewCommand({
-      id: "input.focus",
-      title: language.t("command.input.focus"),
-      keybind: "ctrl+l",
-      onSelect: () => focusInput(),
-    }),
-    terminalCommand({
-      id: "terminal.new",
-      title: language.t("command.terminal.new"),
-      description: language.t("command.terminal.new.description"),
-      keybind: "ctrl+alt+t",
-      onSelect: () => {
-        if (terminal.all().length > 0) terminal.new()
-        view().terminal.open()
-      },
-    }),
-  ])
-
-  const messageCommands = createMemo(() => [
-    sessionCommand({
-      id: "message.previous",
-      title: language.t("command.message.previous"),
-      description: language.t("command.message.previous.description"),
-      keybind: "mod+arrowup",
-      disabled: !params.id,
-      onSelect: () => navigateMessageByOffset(-1),
-    }),
-    sessionCommand({
-      id: "message.next",
-      title: language.t("command.message.next"),
-      description: language.t("command.message.next.description"),
-      keybind: "mod+arrowdown",
-      disabled: !params.id,
-      onSelect: () => navigateMessageByOffset(1),
-    }),
-  ])
-
-  const agentCommands = createMemo(() => [
-    modelCommand({
-      id: "model.choose",
-      title: language.t("command.model.choose"),
-      description: language.t("command.model.choose.description"),
-      keybind: "mod+'",
-      slash: "model",
-      onSelect: () => dialog.show(() => <DialogSelectModel />),
-    }),
-    mcpCommand({
-      id: "mcp.toggle",
-      title: language.t("command.mcp.toggle"),
-      description: language.t("command.mcp.toggle.description"),
-      keybind: "mod+;",
-      slash: "mcp",
-      onSelect: () => dialog.show(() => <DialogSelectMcp />),
-    }),
-    agentCommand({
-      id: "agent.cycle",
-      title: language.t("command.agent.cycle"),
-      description: language.t("command.agent.cycle.description"),
-      keybind: "mod+.",
-      slash: "agent",
-      onSelect: () => local.agent.move(1),
-    }),
-    agentCommand({
-      id: "agent.cycle.reverse",
-      title: language.t("command.agent.cycle.reverse"),
-      description: language.t("command.agent.cycle.reverse.description"),
-      keybind: "shift+mod+.",
-      onSelect: () => local.agent.move(-1),
-    }),
-    modelCommand({
-      id: "model.variant.cycle",
-      title: language.t("command.model.variant.cycle"),
-      description: language.t("command.model.variant.cycle.description"),
-      keybind: "shift+mod+d",
-      onSelect: () => {
-        local.model.variant.cycle()
-      },
-    }),
-  ])
-
   const isAutoAcceptActive = () => {
     const sessionID = params.id
     if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
     return permission.isAutoAcceptingDirectory(sdk.directory)
   }
+  command.register("session", () => {
+    const share =
+      sync.data.config.share === "disabled"
+        ? []
+        : [
+            sessionCommand({
+              id: "session.share",
+              title: info()?.share?.url
+                ? language.t("session.share.copy.copyLink")
+                : language.t("command.session.share"),
+              description: info()?.share?.url
+                ? language.t("toast.session.share.success.description")
+                : language.t("command.session.share.description"),
+              slash: "share",
+              disabled: !params.id,
+              onSelect: async () => {
+                if (!params.id) return
 
-  const permissionCommands = createMemo(() => [
-    permissionsCommand({
-      id: "permissions.autoaccept",
-      title: isAutoAcceptActive()
-        ? language.t("command.permissions.autoaccept.disable")
-        : language.t("command.permissions.autoaccept.enable"),
-      keybind: "mod+shift+a",
-      disabled: false,
-      onSelect: () => {
-        const sessionID = params.id
-        if (sessionID) {
-          permission.toggleAutoAccept(sessionID, sdk.directory)
-        } else {
-          permission.toggleAutoAcceptDirectory(sdk.directory)
-        }
-        const active = sessionID
-          ? permission.isAutoAccepting(sessionID, sdk.directory)
-          : permission.isAutoAcceptingDirectory(sdk.directory)
-        showToast({
-          title: active
-            ? language.t("toast.permissions.autoaccept.on.title")
-            : language.t("toast.permissions.autoaccept.off.title"),
-          description: active
-            ? language.t("toast.permissions.autoaccept.on.description")
-            : language.t("toast.permissions.autoaccept.off.description"),
-        })
-      },
-    }),
-  ])
+                const write = (value: string) => {
+                  const body = typeof document === "undefined" ? undefined : document.body
+                  if (body) {
+                    const textarea = document.createElement("textarea")
+                    textarea.value = value
+                    textarea.setAttribute("readonly", "")
+                    textarea.style.position = "fixed"
+                    textarea.style.opacity = "0"
+                    textarea.style.pointerEvents = "none"
+                    body.appendChild(textarea)
+                    textarea.select()
+                    const copied = document.execCommand("copy")
+                    body.removeChild(textarea)
+                    if (copied) return Promise.resolve(true)
+                  }
 
-  const sessionActionCommands = createMemo(() => [
-    sessionCommand({
-      id: "session.undo",
-      title: language.t("command.session.undo"),
-      description: language.t("command.session.undo.description"),
-      slash: "undo",
-      disabled: !params.id || visibleUserMessages().length === 0,
-      onSelect: async () => {
-        const sessionID = params.id
-        if (!sessionID) return
-        if (status()?.type !== "idle") {
-          await sdk.client.session.abort({ sessionID }).catch(() => {})
-        }
-        const revert = info()?.revert?.messageID
-        const message = findLast(userMessages(), (x) => !revert || x.id < revert)
-        if (!message) return
-        await sdk.client.session.revert({ sessionID, messageID: message.id })
-        const parts = sync.data.part[message.id]
-        if (parts) {
-          const restored = extractPromptFromParts(parts, { directory: sdk.directory })
-          prompt.set(restored)
-        }
-        const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
-        setActiveMessage(priorMessage)
-      },
-    }),
-    sessionCommand({
-      id: "session.redo",
-      title: language.t("command.session.redo"),
-      description: language.t("command.session.redo.description"),
-      slash: "redo",
-      disabled: !params.id || !info()?.revert?.messageID,
-      onSelect: async () => {
-        const sessionID = params.id
-        if (!sessionID) return
-        const revertMessageID = info()?.revert?.messageID
-        if (!revertMessageID) return
-        const nextMessage = userMessages().find((x) => x.id > revertMessageID)
-        if (!nextMessage) {
-          await sdk.client.session.unrevert({ sessionID })
-          prompt.reset()
-          const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
-          setActiveMessage(lastMsg)
-          return
-        }
-        await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
-        const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
-        setActiveMessage(priorMsg)
-      },
-    }),
-    sessionCommand({
-      id: "session.compact",
-      title: language.t("command.session.compact"),
-      description: language.t("command.session.compact.description"),
-      slash: "compact",
-      disabled: !params.id || visibleUserMessages().length === 0,
-      onSelect: async () => {
-        const sessionID = params.id
-        if (!sessionID) return
-        const model = local.model.current()
-        if (!model) {
-          showToast({
-            title: language.t("toast.model.none.title"),
-            description: language.t("toast.model.none.description"),
-          })
-          return
-        }
-        await sdk.client.session.summarize({
-          sessionID,
-          modelID: model.id,
-          providerID: model.provider.id,
-        })
-      },
-    }),
-    sessionCommand({
-      id: "session.fork",
-      title: language.t("command.session.fork"),
-      description: language.t("command.session.fork.description"),
-      slash: "fork",
-      disabled: !params.id || visibleUserMessages().length === 0,
-      onSelect: () => dialog.show(() => <DialogFork />),
-    }),
-  ])
+                  const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
+                  if (!clipboard?.writeText) return Promise.resolve(false)
+                  return clipboard.writeText(value).then(
+                    () => true,
+                    () => false,
+                  )
+                }
 
-  const shareCommands = createMemo(() => {
-    if (sync.data.config.share === "disabled") return []
-    return [
-      sessionCommand({
-        id: "session.share",
-        title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
-        description: info()?.share?.url
-          ? language.t("toast.session.share.success.description")
-          : language.t("command.session.share.description"),
-        slash: "share",
-        disabled: !params.id,
-        onSelect: async () => {
-          if (!params.id) return
+                const copy = async (url: string, existing: boolean) => {
+                  const ok = await write(url)
+                  if (!ok) {
+                    showToast({
+                      title: language.t("toast.session.share.copyFailed.title"),
+                      variant: "error",
+                    })
+                    return
+                  }
 
-          const write = (value: string) => {
-            const body = typeof document === "undefined" ? undefined : document.body
-            if (body) {
-              const textarea = document.createElement("textarea")
-              textarea.value = value
-              textarea.setAttribute("readonly", "")
-              textarea.style.position = "fixed"
-              textarea.style.opacity = "0"
-              textarea.style.pointerEvents = "none"
-              body.appendChild(textarea)
-              textarea.select()
-              const copied = document.execCommand("copy")
-              body.removeChild(textarea)
-              if (copied) return Promise.resolve(true)
-            }
+                  showToast({
+                    title: existing
+                      ? language.t("session.share.copy.copied")
+                      : language.t("toast.session.share.success.title"),
+                    description: language.t("toast.session.share.success.description"),
+                    variant: "success",
+                  })
+                }
 
-            const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
-            if (!clipboard?.writeText) return Promise.resolve(false)
-            return clipboard.writeText(value).then(
-              () => true,
-              () => false,
-            )
-          }
+                const existing = info()?.share?.url
+                if (existing) {
+                  await copy(existing, true)
+                  return
+                }
 
-          const copy = async (url: string, existing: boolean) => {
-            const ok = await write(url)
-            if (!ok) {
-              showToast({
-                title: language.t("toast.session.share.copyFailed.title"),
-                variant: "error",
-              })
-              return
-            }
+                const url = await sdk.client.session
+                  .share({ sessionID: params.id })
+                  .then((res) => res.data?.share?.url)
+                  .catch(() => undefined)
+                if (!url) {
+                  showToast({
+                    title: language.t("toast.session.share.failed.title"),
+                    description: language.t("toast.session.share.failed.description"),
+                    variant: "error",
+                  })
+                  return
+                }
 
+                await copy(url, false)
+              },
+            }),
+            sessionCommand({
+              id: "session.unshare",
+              title: language.t("command.session.unshare"),
+              description: language.t("command.session.unshare.description"),
+              slash: "unshare",
+              disabled: !params.id || !info()?.share?.url,
+              onSelect: async () => {
+                if (!params.id) return
+                await sdk.client.session
+                  .unshare({ sessionID: params.id })
+                  .then(() =>
+                    showToast({
+                      title: language.t("toast.session.unshare.success.title"),
+                      description: language.t("toast.session.unshare.success.description"),
+                      variant: "success",
+                    }),
+                  )
+                  .catch(() =>
+                    showToast({
+                      title: language.t("toast.session.unshare.failed.title"),
+                      description: language.t("toast.session.unshare.failed.description"),
+                      variant: "error",
+                    }),
+                  )
+              },
+            }),
+          ]
+
+    return [
+      sessionCommand({
+        id: "session.new",
+        title: language.t("command.session.new"),
+        keybind: "mod+shift+s",
+        slash: "new",
+        onSelect: () => navigate(`/${params.dir}/session`),
+      }),
+      fileCommand({
+        id: "file.open",
+        title: language.t("command.file.open"),
+        description: language.t("palette.search.placeholder"),
+        keybind: "mod+p",
+        slash: "open",
+        onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
+      }),
+      fileCommand({
+        id: "tab.close",
+        title: language.t("command.tab.close"),
+        keybind: "mod+w",
+        disabled: !closableTab(),
+        onSelect: () => {
+          const tab = closableTab()
+          if (!tab) return
+          tabs().close(tab)
+        },
+      }),
+      contextCommand({
+        id: "context.addSelection",
+        title: language.t("command.context.addSelection"),
+        description: language.t("command.context.addSelection.description"),
+        keybind: "mod+shift+l",
+        disabled: !canAddSelectionContext(),
+        onSelect: () => {
+          const tab = activeFileTab()
+          if (!tab) return
+          const path = file.pathFromTab(tab)
+          if (!path) return
+
+          const range = file.selectedLines(path) as SelectedLineRange | null | undefined
+          if (!range) {
             showToast({
-              title: existing
-                ? language.t("session.share.copy.copied")
-                : language.t("toast.session.share.success.title"),
-              description: language.t("toast.session.share.success.description"),
-              variant: "success",
+              title: language.t("toast.context.noLineSelection.title"),
+              description: language.t("toast.context.noLineSelection.description"),
             })
+            return
           }
 
-          const existing = info()?.share?.url
-          if (existing) {
-            await copy(existing, true)
+          addSelectionToContext(path, selectionFromLines(range))
+        },
+      }),
+      viewCommand({
+        id: "terminal.toggle",
+        title: language.t("command.terminal.toggle"),
+        keybind: "ctrl+`",
+        slash: "terminal",
+        onSelect: () => view().terminal.toggle(),
+      }),
+      viewCommand({
+        id: "review.toggle",
+        title: language.t("command.review.toggle"),
+        keybind: "mod+shift+r",
+        onSelect: () => view().reviewPanel.toggle(),
+      }),
+      viewCommand({
+        id: "fileTree.toggle",
+        title: language.t("command.fileTree.toggle"),
+        keybind: "mod+\\",
+        onSelect: () => layout.fileTree.toggle(),
+      }),
+      viewCommand({
+        id: "input.focus",
+        title: language.t("command.input.focus"),
+        keybind: "ctrl+l",
+        onSelect: focusInput,
+      }),
+      terminalCommand({
+        id: "terminal.new",
+        title: language.t("command.terminal.new"),
+        description: language.t("command.terminal.new.description"),
+        keybind: "ctrl+alt+t",
+        onSelect: () => {
+          if (terminal.all().length > 0) terminal.new()
+          view().terminal.open()
+        },
+      }),
+      sessionCommand({
+        id: "message.previous",
+        title: language.t("command.message.previous"),
+        description: language.t("command.message.previous.description"),
+        keybind: "mod+arrowup",
+        disabled: !params.id,
+        onSelect: () => navigateMessageByOffset(-1),
+      }),
+      sessionCommand({
+        id: "message.next",
+        title: language.t("command.message.next"),
+        description: language.t("command.message.next.description"),
+        keybind: "mod+arrowdown",
+        disabled: !params.id,
+        onSelect: () => navigateMessageByOffset(1),
+      }),
+      modelCommand({
+        id: "model.choose",
+        title: language.t("command.model.choose"),
+        description: language.t("command.model.choose.description"),
+        keybind: "mod+'",
+        slash: "model",
+        onSelect: () => dialog.show(() => <DialogSelectModel />),
+      }),
+      mcpCommand({
+        id: "mcp.toggle",
+        title: language.t("command.mcp.toggle"),
+        description: language.t("command.mcp.toggle.description"),
+        keybind: "mod+;",
+        slash: "mcp",
+        onSelect: () => dialog.show(() => <DialogSelectMcp />),
+      }),
+      agentCommand({
+        id: "agent.cycle",
+        title: language.t("command.agent.cycle"),
+        description: language.t("command.agent.cycle.description"),
+        keybind: "mod+.",
+        slash: "agent",
+        onSelect: () => local.agent.move(1),
+      }),
+      agentCommand({
+        id: "agent.cycle.reverse",
+        title: language.t("command.agent.cycle.reverse"),
+        description: language.t("command.agent.cycle.reverse.description"),
+        keybind: "shift+mod+.",
+        onSelect: () => local.agent.move(-1),
+      }),
+      modelCommand({
+        id: "model.variant.cycle",
+        title: language.t("command.model.variant.cycle"),
+        description: language.t("command.model.variant.cycle.description"),
+        keybind: "shift+mod+d",
+        onSelect: () => local.model.variant.cycle(),
+      }),
+      permissionsCommand({
+        id: "permissions.autoaccept",
+        title: isAutoAcceptActive()
+          ? language.t("command.permissions.autoaccept.disable")
+          : language.t("command.permissions.autoaccept.enable"),
+        keybind: "mod+shift+a",
+        disabled: false,
+        onSelect: () => {
+          const sessionID = params.id
+          if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
+          else permission.toggleAutoAcceptDirectory(sdk.directory)
+
+          const active = sessionID
+            ? permission.isAutoAccepting(sessionID, sdk.directory)
+            : permission.isAutoAcceptingDirectory(sdk.directory)
+          showToast({
+            title: active
+              ? language.t("toast.permissions.autoaccept.on.title")
+              : language.t("toast.permissions.autoaccept.off.title"),
+            description: active
+              ? language.t("toast.permissions.autoaccept.on.description")
+              : language.t("toast.permissions.autoaccept.off.description"),
+          })
+        },
+      }),
+      sessionCommand({
+        id: "session.undo",
+        title: language.t("command.session.undo"),
+        description: language.t("command.session.undo.description"),
+        slash: "undo",
+        disabled: !params.id || visibleUserMessages().length === 0,
+        onSelect: async () => {
+          const sessionID = params.id
+          if (!sessionID) return
+          if (status().type !== "idle") {
+            await sdk.client.session.abort({ sessionID }).catch(() => {})
+          }
+          const revert = info()?.revert?.messageID
+          const message = findLast(userMessages(), (x) => !revert || x.id < revert)
+          if (!message) return
+          await sdk.client.session.revert({ sessionID, messageID: message.id })
+          const parts = sync.data.part[message.id]
+          if (parts) {
+            const restored = extractPromptFromParts(parts, { directory: sdk.directory })
+            prompt.set(restored)
+          }
+          const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
+          setActiveMessage(priorMessage)
+        },
+      }),
+      sessionCommand({
+        id: "session.redo",
+        title: language.t("command.session.redo"),
+        description: language.t("command.session.redo.description"),
+        slash: "redo",
+        disabled: !params.id || !info()?.revert?.messageID,
+        onSelect: async () => {
+          const sessionID = params.id
+          if (!sessionID) return
+          const revertMessageID = info()?.revert?.messageID
+          if (!revertMessageID) return
+          const nextMessage = userMessages().find((x) => x.id > revertMessageID)
+          if (!nextMessage) {
+            await sdk.client.session.unrevert({ sessionID })
+            prompt.reset()
+            const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
+            setActiveMessage(lastMsg)
             return
           }
-
-          const url = await sdk.client.session
-            .share({ sessionID: params.id })
-            .then((res) => res.data?.share?.url)
-            .catch(() => undefined)
-          if (!url) {
+          await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+          const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
+          setActiveMessage(priorMsg)
+        },
+      }),
+      sessionCommand({
+        id: "session.compact",
+        title: language.t("command.session.compact"),
+        description: language.t("command.session.compact.description"),
+        slash: "compact",
+        disabled: !params.id || visibleUserMessages().length === 0,
+        onSelect: async () => {
+          const sessionID = params.id
+          if (!sessionID) return
+          const model = local.model.current()
+          if (!model) {
             showToast({
-              title: language.t("toast.session.share.failed.title"),
-              description: language.t("toast.session.share.failed.description"),
-              variant: "error",
+              title: language.t("toast.model.none.title"),
+              description: language.t("toast.model.none.description"),
             })
             return
           }
-
-          await copy(url, false)
+          await sdk.client.session.summarize({
+            sessionID,
+            modelID: model.id,
+            providerID: model.provider.id,
+          })
         },
       }),
       sessionCommand({
-        id: "session.unshare",
-        title: language.t("command.session.unshare"),
-        description: language.t("command.session.unshare.description"),
-        slash: "unshare",
-        disabled: !params.id || !info()?.share?.url,
-        onSelect: async () => {
-          if (!params.id) return
-          await sdk.client.session
-            .unshare({ sessionID: params.id })
-            .then(() =>
-              showToast({
-                title: language.t("toast.session.unshare.success.title"),
-                description: language.t("toast.session.unshare.success.description"),
-                variant: "success",
-              }),
-            )
-            .catch(() =>
-              showToast({
-                title: language.t("toast.session.unshare.failed.title"),
-                description: language.t("toast.session.unshare.failed.description"),
-                variant: "error",
-              }),
-            )
-        },
+        id: "session.fork",
+        title: language.t("command.session.fork"),
+        description: language.t("command.session.fork.description"),
+        slash: "fork",
+        disabled: !params.id || visibleUserMessages().length === 0,
+        onSelect: () => dialog.show(() => <DialogFork />),
       }),
+      ...share,
     ]
   })
-
-  command.register("session", () =>
-    [
-      sessionCommands(),
-      fileCommands(),
-      contextCommands(),
-      viewCommands(),
-      messageCommands(),
-      agentCommands(),
-      permissionCommands(),
-      sessionActionCommands(),
-      shareCommands(),
-    ].flatMap((x) => x),
-  )
 }