mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-14 12:44:36 +00:00
chore: cleanup (#17197)
This commit is contained in:
159
packages/app/src/components/dialog-custom-provider-form.ts
Normal file
159
packages/app/src/components/dialog-custom-provider-form.ts
Normal 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: {} })
|
||||||
82
packages/app/src/components/dialog-custom-provider.test.ts
Normal file
82
packages/app/src/components/dialog-custom-provider.test.ts
Normal 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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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[] = []
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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}`)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user