From dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:32:05 -0500 Subject: [PATCH] chore: cleanup (#17197) --- .../components/dialog-custom-provider-form.ts | 159 ++++ .../components/dialog-custom-provider.test.ts | 82 ++ .../src/components/dialog-custom-provider.tsx | 268 ++---- .../app/src/components/dialog-select-file.tsx | 10 +- packages/app/src/components/prompt-input.tsx | 9 +- .../src/components/session-context-usage.tsx | 10 +- .../components/session/session-new-view.tsx | 1 - packages/app/src/context/highlights.tsx | 15 +- packages/app/src/context/layout.tsx | 55 +- packages/app/src/hooks/use-providers.ts | 34 +- packages/app/src/pages/session.tsx | 126 ++- .../composer/session-composer-region.tsx | 31 +- .../session/composer/session-todo-dock.tsx | 46 +- packages/app/src/pages/session/file-tabs.tsx | 12 +- .../app/src/pages/session/helpers.test.ts | 73 +- packages/app/src/pages/session/helpers.ts | 74 +- packages/app/src/pages/session/review-tab.tsx | 8 - .../src/pages/session/session-mobile-tabs.tsx | 41 - .../src/pages/session/session-side-panel.tsx | 45 +- .../app/src/pages/session/terminal-panel.tsx | 29 +- .../pages/session/use-session-commands.tsx | 766 +++++++++--------- 21 files changed, 1077 insertions(+), 817 deletions(-) create mode 100644 packages/app/src/components/dialog-custom-provider-form.ts create mode 100644 packages/app/src/components/dialog-custom-provider.test.ts delete mode 100644 packages/app/src/pages/session/session-mobile-tabs.tsx 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 + +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 +} + +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() + 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() + 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["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 -} - -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() - 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() - 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({ - 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} /> 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} /> 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} /> setForm("apiKey", v)} + onChange={(v) => setField("apiKey", v)} /> @@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) { {(m, i) => ( -
+
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} />
@@ -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} />
{language.t("provider.custom.headers.label")} {(h, i) => ( -
+
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} />
@@ -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} />
ReturnType["tabs"]> language: ReturnType }) { + 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() 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 = (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 = (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() 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({ version: undefined })) - const [from, setFrom] = createSignal(undefined) - const [to, setTo] = createSignal(undefined) + const [range, setRange] = createStore({ + from: undefined as string | undefined, + to: undefined as string | undefined, + }) const state = { started: false } let timer: ReturnType | 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: next, + }) + return + } + + 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: open, + reviewOpen: [path], }) return } - if (same(current.reviewOpen, open)) return - setStore("sessionView", session, "reviewOpen", open) + 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() {
- setStore("mobileTab", "session")} - onChanges={() => setStore("mobileTab", "changes")} - /> + + + + setStore("mobileTab", "session")} + > + {language.t("session.tab.session")} + + setStore("mobileTab", "changes")} + > + {hasReview() + ? language.t("session.review.filesChanged", { count: reviewCount() }) + : language.t("session.review.change.other")} + + + + {/* Session panel */}
- { - 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`) - }} - /> +
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() + 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`, }} > -
+
setStore("body", el)}> 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: { >
0.1, }} @@ -169,7 +173,7 @@ export function SessionTodoDock(props: { opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, }} > - +
@@ -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, }} />
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 + all: Accessor +} + +type TabsInput = { + tabs: Accessor + pathFromTab: (tab: string) => string | undefined + normalizeTab: (tab: string) => string + review?: Accessor + hasReview?: Accessor +} + +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() + 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 ( -
- {props.children} -
- ) -} - 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 ( - - - - - {language.t("session.tab.session")} - - - {props.hasReview - ? language.t("session.review.filesChanged", { count: props.reviewCount }) - : language.t("session.review.change.other")} - - - - - ) -} 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: { {(tab) => } - +
- +
@@ -354,10 +339,10 @@ export function SessionSidePanel(props: { {(tab) => { - const path = createMemo(() => file.pathFromTab(tab)) + const path = file.pathFromTab(tab) return (
- {(p) => } + {(p) => }
) }} 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() { > - - {(id) => ( - - {(pty) => } - - )} - + {(pty) => }
{(id) => ( - + pty.id === id)}> {(pty) => (
- - {(draggedId) => ( - + + {(id) => ( + pty.id === id)}> {(t) => (
{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(() => ), - }), - 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(() => ), - }), - mcpCommand({ - id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), - keybind: "mod+;", - slash: "mcp", - onSelect: () => dialog.show(() => ), - }), - 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(() => ), - }), - ]) + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } + + 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 + } + + 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 existing = info()?.share?.url + if (existing) { + await copy(existing, true) + 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", + }), + ) + }, + }), + ] - 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 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 clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard - if (!clipboard?.writeText) return Promise.resolve(false) - return clipboard.writeText(value).then( - () => true, - () => false, - ) - } - - 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 - } + 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(() => ), + }), + 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", - }) - } - - const existing = info()?.share?.url - if (existing) { - await copy(existing, true) - 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", + title: language.t("toast.context.noLineSelection.title"), + description: language.t("toast.context.noLineSelection.description"), }) return } - await copy(url, false) + 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: "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: "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(() => ), + }), + mcpCommand({ + id: "mcp.toggle", + title: language.t("command.mcp.toggle"), + description: language.t("command.mcp.toggle.description"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }), + 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 + } + 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(() => ), + }), + ...share, ] }) - - command.register("session", () => - [ - sessionCommands(), - fileCommands(), - contextCommands(), - viewCommands(), - messageCommands(), - agentCommands(), - permissionCommands(), - sessionActionCommands(), - shareCommands(), - ].flatMap((x) => x), - ) }