chore: cleanup (#17197)

This commit is contained in:
Adam
2026-03-12 11:32:05 -05:00
committed by GitHub
parent 0e077f7483
commit dce7eceb28
21 changed files with 1077 additions and 817 deletions

View File

@@ -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: {} })

View File

@@ -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,
})
})
})

View File

@@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { For } from "solid-js" import { batch, For } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
import { DialogSelectProvider } from "./dialog-select-provider" 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 = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
} }
@@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) {
name: "", name: "",
baseURL: "", baseURL: "",
apiKey: "", apiKey: "",
models: [{ id: "", name: "" }], models: [modelRow()],
headers: [{ key: "", value: "" }], headers: [headerRow()],
saving: false, saving: false,
}) err: {},
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
}) })
const goBack = () => { const goBack = () => {
@@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) {
} }
const addModel = () => { const addModel = () => {
setForm("models", (v) => [...v, { id: "", name: "" }]) setForm(
setErrors("models", (v) => [...v, {}]) "models",
produce((rows) => {
rows.push(modelRow())
}),
)
} }
const removeModel = (index: number) => { const removeModel = (index: number) => {
if (form.models.length <= 1) return if (form.models.length <= 1) return
setForm("models", (v) => v.filter((_, i) => i !== index)) setForm(
setErrors("models", (v) => v.filter((_, i) => i !== index)) "models",
produce((rows) => {
rows.splice(index, 1)
}),
)
} }
const addHeader = () => { const addHeader = () => {
setForm("headers", (v) => [...v, { key: "", value: "" }]) setForm(
setErrors("headers", (v) => [...v, {}]) "headers",
produce((rows) => {
rows.push(headerRow())
}),
)
} }
const removeHeader = (index: number) => { const removeHeader = (index: number) => {
if (form.headers.length <= 1) return if (form.headers.length <= 1) return
setForm("headers", (v) => v.filter((_, i) => i !== index)) setForm(
setErrors("headers", (v) => v.filter((_, i) => i !== index)) "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 = () => { const validate = () => {
@@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) {
disabledProviders: globalSync.data.config.disabled_providers ?? [], disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), 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 return output.result
} }
@@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}
value={form.providerID} value={form.providerID}
onChange={(v) => setForm("providerID", v)} onChange={(v) => setField("providerID", v)}
validationState={errors.providerID ? "invalid" : undefined} validationState={form.err.providerID ? "invalid" : undefined}
error={errors.providerID} error={form.err.providerID}
/> />
<TextField <TextField
label={language.t("provider.custom.field.name.label")} label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")} placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name} value={form.name}
onChange={(v) => setForm("name", v)} onChange={(v) => setField("name", v)}
validationState={errors.name ? "invalid" : undefined} validationState={form.err.name ? "invalid" : undefined}
error={errors.name} error={form.err.name}
/> />
<TextField <TextField
label={language.t("provider.custom.field.baseURL.label")} label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")} placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL} value={form.baseURL}
onChange={(v) => setForm("baseURL", v)} onChange={(v) => setField("baseURL", v)}
validationState={errors.baseURL ? "invalid" : undefined} validationState={form.err.baseURL ? "invalid" : undefined}
error={errors.baseURL} error={form.err.baseURL}
/> />
<TextField <TextField
label={language.t("provider.custom.field.apiKey.label")} label={language.t("provider.custom.field.apiKey.label")}
placeholder={language.t("provider.custom.field.apiKey.placeholder")} placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")} description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey} value={form.apiKey}
onChange={(v) => setForm("apiKey", v)} onChange={(v) => setField("apiKey", v)}
/> />
</div> </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> <label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
<For each={form.models}> <For each={form.models}>
{(m, i) => ( {(m, i) => (
<div class="flex gap-2 items-start"> <div class="flex gap-2 items-start" data-row={m.row}>
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label={language.t("provider.custom.models.id.label")} label={language.t("provider.custom.models.id.label")}
hideLabel hideLabel
placeholder={language.t("provider.custom.models.id.placeholder")} placeholder={language.t("provider.custom.models.id.placeholder")}
value={m.id} value={m.id}
onChange={(v) => setForm("models", i(), "id", v)} onChange={(v) => setModel(i(), "id", v)}
validationState={errors.models[i()]?.id ? "invalid" : undefined} validationState={m.err.id ? "invalid" : undefined}
error={errors.models[i()]?.id} error={m.err.id}
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel hideLabel
placeholder={language.t("provider.custom.models.name.placeholder")} placeholder={language.t("provider.custom.models.name.placeholder")}
value={m.name} value={m.name}
onChange={(v) => setForm("models", i(), "name", v)} onChange={(v) => setModel(i(), "name", v)}
validationState={errors.models[i()]?.name ? "invalid" : undefined} validationState={m.err.name ? "invalid" : undefined}
error={errors.models[i()]?.name} error={m.err.name}
/> />
</div> </div>
<IconButton <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> <label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
<For each={form.headers}> <For each={form.headers}>
{(h, i) => ( {(h, i) => (
<div class="flex gap-2 items-start"> <div class="flex gap-2 items-start" data-row={h.row}>
<div class="flex-1"> <div class="flex-1">
<TextField <TextField
label={language.t("provider.custom.headers.key.label")} label={language.t("provider.custom.headers.key.label")}
hideLabel hideLabel
placeholder={language.t("provider.custom.headers.key.placeholder")} placeholder={language.t("provider.custom.headers.key.placeholder")}
value={h.key} value={h.key}
onChange={(v) => setForm("headers", i(), "key", v)} onChange={(v) => setHeader(i(), "key", v)}
validationState={errors.headers[i()]?.key ? "invalid" : undefined} validationState={h.err.key ? "invalid" : undefined}
error={errors.headers[i()]?.key} error={h.err.key}
/> />
</div> </div>
<div class="flex-1"> <div class="flex-1">
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) {
hideLabel hideLabel
placeholder={language.t("provider.custom.headers.value.placeholder")} placeholder={language.t("provider.custom.headers.value.placeholder")}
value={h.value} value={h.value}
onChange={(v) => setForm("headers", i(), "value", v)} onChange={(v) => setHeader(i(), "value", v)}
validationState={errors.headers[i()]?.value ? "invalid" : undefined} validationState={h.err.value ? "invalid" : undefined}
error={errors.headers[i()]?.value} error={h.err.value}
/> />
</div> </div>
<IconButton <IconButton

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
}, },
review: { review: {
open: createMemo(() => s().reviewOpen), open: createMemo(() => s().reviewOpen ?? []),
setOpen(open: string[]) { 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 session = key()
const current = store.sessionView[session] const current = store.sessionView[session]
if (!current) { if (!current) {
setStore("sessionView", session, { setStore("sessionView", session, {
scroll: {}, scroll: {},
reviewOpen: open, reviewOpen: [path],
}) })
return return
} }
if (same(current.reviewOpen, open)) return if (!current.reviewOpen) {
setStore("sessionView", session, "reviewOpen", open) 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)
}, },
}, },
} }

View File

@@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders)
export function useProviders() { export function useProviders() {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const params = useParams() const params = useParams()
const currentDirectory = createMemo(() => decode64(params.dir) ?? "") const dir = createMemo(() => decode64(params.dir) ?? "")
const providers = createMemo(() => { const providers = () => {
if (currentDirectory()) { if (dir()) {
const [projectStore] = globalSync.child(currentDirectory()) const [projectStore] = globalSync.child(dir())
return projectStore.provider return projectStore.provider
} }
return globalSync.data.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 { return {
all: createMemo(() => providers().all), all: () => providers().all,
default: createMemo(() => providers().default), default: () => providers().default,
popular, popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)),
connected, connected: () => {
paid, 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)),
)
},
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,13 @@
import { describe, expect, test } from "bun:test" 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", () => { describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => { test("opens and loads selected review file", () => {
@@ -87,3 +95,66 @@ describe("getTabReorderIndex", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() 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()
})
})
})

View File

@@ -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 { 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) => { export const focusTerminalById = (id: string) => {
const wrapper = document.getElementById(`terminal-wrapper-${id}`) const wrapper = document.getElementById(`terminal-wrapper-${id}`)

View File

@@ -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) { export function SessionReviewTab(props: SessionReviewTabProps) {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let restoreFrame: number | undefined let restoreFrame: number | undefined

View File

@@ -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>
)
}

View File

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

View File

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

View File

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