summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/app/src/components/dialog-custom-provider-form.ts159
-rw-r--r--packages/app/src/components/dialog-custom-provider.test.ts82
-rw-r--r--packages/app/src/components/dialog-custom-provider.tsx268
-rw-r--r--packages/app/src/components/dialog-select-file.tsx10
-rw-r--r--packages/app/src/components/prompt-input.tsx9
-rw-r--r--packages/app/src/components/session-context-usage.tsx10
-rw-r--r--packages/app/src/components/session/session-new-view.tsx1
-rw-r--r--packages/app/src/context/highlights.tsx15
-rw-r--r--packages/app/src/context/layout.tsx55
-rw-r--r--packages/app/src/hooks/use-providers.ts34
-rw-r--r--packages/app/src/pages/session.tsx126
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx31
-rw-r--r--packages/app/src/pages/session/composer/session-todo-dock.tsx46
-rw-r--r--packages/app/src/pages/session/file-tabs.tsx12
-rw-r--r--packages/app/src/pages/session/helpers.test.ts73
-rw-r--r--packages/app/src/pages/session/helpers.ts74
-rw-r--r--packages/app/src/pages/session/review-tab.tsx8
-rw-r--r--packages/app/src/pages/session/session-mobile-tabs.tsx41
-rw-r--r--packages/app/src/pages/session/session-side-panel.tsx45
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx29
-rw-r--r--packages/app/src/pages/session/use-session-commands.tsx756
21 files changed, 1072 insertions, 812 deletions
diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts
new file mode 100644
index 000000000..92d235c3b
--- /dev/null
+++ b/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: {} })
diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts
new file mode 100644
index 000000000..8cfd78ebe
--- /dev/null
+++ b/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,
+ })
+ })
+})
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx
index 017b85a2c..4d220a0b1 100644
--- a/packages/app/src/components/dialog-custom-provider.tsx
+++ b/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
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
index 9b88cff90..3d4dbecbd 100644
--- a/packages/app/src/components/dialog-select-file.tsx
+++ b/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")
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index f1a33e75f..e129b499a 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/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[] = []
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index 99e6c13a3..7379833f8 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/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
}
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
index 52251dbb2..e4ef36393 100644
--- a/packages/app/src/components/session/session-new-view.tsx
+++ b/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) {
diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx
index 476209e41..058f7cc4b 100644
--- a/packages/app/src/context/highlights.tsx
+++ b/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
},
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 5199e5a26..78928118d 100644
--- a/packages/app/src/context/layout.tsx
+++ b/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)
},
},
}
diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts
index 9ef5272ef..a25f8b4b2 100644
--- a/packages/app/src/hooks/use-providers.ts
+++ b/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)),
+ )
+ },
}
}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 2454acf4d..7642ac165 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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>
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index 964bf18dd..6d60d81b5 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/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")}
diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx
index baea51593..c7907bb54 100644
--- a/packages/app/src/pages/session/composer/session-todo-dock.tsx
+++ b/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>
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx
index 4b322368f..a3379905d 100644
--- a/packages/app/src/pages/session/file-tabs.tsx
+++ b/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
diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts
index 9c77c34af..047946fc1 100644
--- a/packages/app/src/pages/session/helpers.test.ts
+++ b/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()
+ })
+ })
+})
diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts
index 2da5ce6b8..c3571f3ff 100644
--- a/packages/app/src/pages/session/helpers.ts
+++ b/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}`)
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index 142ee7ad9..c073e6214 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/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
diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx
deleted file mode 100644
index f97199b49..000000000
--- a/packages/app/src/pages/session/session-mobile-tabs.tsx
+++ /dev/null
@@ -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>
- )
-}
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx
index 2c499d9f4..3b8b0c96b 100644
--- a/packages/app/src/pages/session/session-side-panel.tsx
+++ b/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>
)
}}
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index c49518656..e78ebecfc 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/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({
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx
index 6799504ca..f5a4c0576 100644
--- a/packages/app/src/pages/session/use-session-commands.tsx
+++ b/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),
- )
}